- commit
- f7b0c938af2310f7dad2888db31f6c65ae06efdf
- parent
- 8d8b8cef78d166245f85b1fc68cefcead1a73552
- parent
- 44c5866a1d410acc2775dae757f116388c992074
- author
- Dami <dami.etoile@gmail.com>
@@ -28,3 +28,105 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2828 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,2929 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE3030 SOFTWARE.31+32+-------------------------------------------------------------------------------33+internal/fileio/fonts/*.woff234+35+IBM Plex Mono (regular, semibold and italic, Latin subset), embedded in the36+web pages 012 writes, as packaged by Fontsource37+(https://fontsource.org/fonts/ibm-plex-mono, @fontsource/ibm-plex-mono). The38+license is also in internal/fileio/fonts/OFL.txt.39+40+Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.41+42+This Font Software is licensed under the SIL Open Font License, Version 1.1.43+This license is copied below, and is also available with a FAQ at:44+http://scripts.sil.org/OFL45+46+47+-----------------------------------------------------------48+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200749+-----------------------------------------------------------50+51+PREAMBLE52+The goals of the Open Font License (OFL) are to stimulate worldwide53+development of collaborative font projects, to support the font creation54+efforts of academic and linguistic communities, and to provide a free and55+open framework in which fonts may be shared and improved in partnership56+with others.57+58+The OFL allows the licensed fonts to be used, studied, modified and59+redistributed freely as long as they are not sold by themselves. The60+fonts, including any derivative works, can be bundled, embedded,61+redistributed and/or sold with any software provided that any reserved62+names are not used by derivative works. The fonts and derivatives,63+however, cannot be released under any other type of license. The64+requirement for fonts to remain under this license does not apply65+to any document created using the fonts or their derivatives.66+67+DEFINITIONS68+"Font Software" refers to the set of files released by the Copyright69+Holder(s) under this license and clearly marked as such. This may70+include source files, build scripts and documentation.71+72+"Reserved Font Name" refers to any names specified as such after the73+copyright statement(s).74+75+"Original Version" refers to the collection of Font Software components as76+distributed by the Copyright Holder(s).77+78+"Modified Version" refers to any derivative made by adding to, deleting,79+or substituting -- in part or in whole -- any of the components of the80+Original Version, by changing formats or by porting the Font Software to a81+new environment.82+83+"Author" refers to any designer, engineer, programmer, technical84+writer or other person who contributed to the Font Software.85+86+PERMISSION & CONDITIONS87+Permission is hereby granted, free of charge, to any person obtaining88+a copy of the Font Software, to use, study, copy, merge, embed, modify,89+redistribute, and sell modified and unmodified copies of the Font90+Software, subject to the following conditions:91+92+1) Neither the Font Software nor any of its individual components,93+in Original or Modified Versions, may be sold by itself.94+95+2) Original or Modified Versions of the Font Software may be bundled,96+redistributed and/or sold with any software, provided that each copy97+contains the above copyright notice and this license. These can be98+included either as stand-alone text files, human-readable headers or99+in the appropriate machine-readable metadata fields within text or100+binary files as long as those fields can be easily viewed by the user.101+102+3) No Modified Version of the Font Software may use the Reserved Font103+Name(s) unless explicit written permission is granted by the corresponding104+Copyright Holder. This restriction only applies to the primary font name as105+presented to the users.106+107+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font108+Software shall not be used to promote, endorse or advertise any109+Modified Version, except to acknowledge the contribution(s) of the110+Copyright Holder(s) and the Author(s) or with their explicit written111+permission.112+113+5) The Font Software, modified or unmodified, in part or in whole,114+must be distributed entirely under this license, and must not be115+distributed under any other license. The requirement for fonts to116+remain under this license does not apply to any document created117+using the Font Software.118+119+TERMINATION120+This license becomes null and void if any of the above conditions are121+not met.122+123+DISCLAIMER124+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,125+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF126+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT127+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE128+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,129+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL130+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING131+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM132+OTHER DEALINGS IN THE FONT SOFTWARE.@@ -47,7 +47,12 @@ rest: building from a clone, importing files, `012 serve` and settings.
4747 ([charts](docs/sheets/charts.md)).4848 - **Files**: a diff-friendly JSON format, import from CSV, TSV, JSON,4949 nushell's NUON, XLSX, SQLite, Parquet and Lotus 1-2-3, export to CSV,50- TSV, JSON, NUON, XLSX and SQLite ([files](docs/files/README.md)).50+ TSV, JSON, NUON, XLSX, SQLite and a self-contained web page with its51+ charts as SVG ([files](docs/files/README.md)).52+- **For agents**: `012 describe`, `get`, `set --dry-run` and `recalc`53+ with JSON results, a Claude Code skill, and `012 mcp`, an MCP server54+ whose writes go through the same checks as yours, with views55+ of ranges and charts in the chat ([agents](docs/agents/README.md)).5156 - **A stage in a pipeline**: `ls | sheet | where size > 1kb` edits a5257 table on the terminal and sends it on with its types, through the5358 nushell module `012 nu --install-module` installs, or as@@ -59,13 +59,7 @@ shared editing in section 3.
5959 |---|---|---|6060 | A workbook of formulas where 012 matches Sheets on purpose (dates, text, rounding, errors, spills), results checked once in Sheets and asserted after; differences chosen on purpose listed in the docs | Formula results people rely on stay put | S |6161 62-### 5. Around the grid63-64-| Item | Result | Size |65-|---|---|---|66-| A sheet or chart exported as a static HTML page in 012's look | Sharing a sheet with someone without 012 | S |67-68-### 6. Agents62+### 5. Agents6963 7064 Agents work on workbooks through the same operations people do (the7165 `Batch`/`Change` path), so their edits are undoable, attributed and@@ -74,9 +68,6 @@ section 3's shared editing with an agent as one participant.
7468 7569 | Step | Result | Size |7670 |---|---|---|77-| `012 describe` (sheets, used ranges, headers, names, tables, regions, charts), `--format json` on every command, `012 set --dry-run` printing the change as a diff, an agents page in the docs and a Claude Code skill installed by 012 | Agents with a shell use 012 well | S |78-| `012 mcp` over stdio on a workbook file: tools (describe, read, write, apply operations, evaluate without writing, find, sort, filter, chart, pivot, run a notebook cell), resources (`012://book/Sheet1!A1:D40`, tables, notebook cells), prompts | Any MCP host works with workbooks | M |79-| MCP Apps views: a sheet or chart rendered as an interactive view inside the host, from the HTML export | Workbooks shown in the chat | S to M |8071 | Live mode: `012 --listen` and `012 mcp --attach`; the agent's cursor and name in the grid, its changes arriving as a suggestion (marked cells, accepted or rejected whole or by cell) unless direct edits are allowed, its own undo, a scope (sheet, range, read-only), elicitation to ask the person | Coworking with an agent in the grid | M to L |8172 8273 ## Later: other transports (shelved)@@ -158,8 +149,15 @@ stream above rather than a design of their own.
158149 - `012 diff` cell by cell, as git's diff command or textconv, and `012 merge-driver` merging cell by cell with conflicts noted on the cells: [Diff and merge in git](docs/files/git.md)159150 - Following files: a linked region follows a CSV, TSV, JSON lines or NUON file as it grows and any importable file as it's rewritten, keeping every row or the last ones, its rows arriving as the change stream: [Following files](docs/files/following.md)160151 - The `.012` format written down with a version, and each release's workbooks kept as fixtures that every later build opens and saves unchanged: [The .012 format](docs/files/format.md#versions), [Releasing](docs/contributing/releasing.md#file-fixtures)152+- Web pages in 012's look: a sheet, a range or a chart as one self-contained `.html` file, charts as SVG, from File > Download and `012 export`: [Files](docs/files/README.md#web-pages)161153 - A crash keeps unsaved work for recovery, restores the terminal and writes a report, locally and in `012 serve`: [Saving](docs/files/saving.md#if-012-crashes)162154 155+**Agents**156+157+- `012 describe`, results as JSON with stable schemas, `012 set --dry-run` as a diff, and a Claude Code skill installed by `012 agent --install-skill`: [Agents](docs/agents/README.md)158+- `012 mcp`, an MCP server on a workbook file: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)159+- MCP Apps views: ranges read and charts made drawn in the chat from the HTML export, where the host supports the extension: [MCP server](docs/agents/mcp.md#views-in-the-chat)160+163161 **Upkeep**164162 165163 - `make check` before every push; demo tapes (`make demos`); annotated version tags with release notes (v0.2.0 onward; v0.1.0 remains on the Go module proxy): [Testing](docs/contributing/testing.md)@@ -0,0 +1,106 @@
1+package main2+3+import (4+ "bytes"5+ _ "embed"6+ "errors"7+ "fmt"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "strings"12+)13+14+// 012 agent --skill and 012 agent --install-skill: the Claude Code15+// skill that teaches agents with a shell to use 012 (see16+// docs/agents/README.md), installed as 012 nu --install-module installs17+// the nushell module.18+19+//go:embed skill/SKILL.md20+var skillSource string21+22+const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir]"23+24+// skillHome is where Claude Code looks for a person's own skills; tests25+// replace it.26+var skillHome = func() (string, error) {27+ home, err := os.UserHomeDir()28+ if err != nil {29+ return "", fmt.Errorf("finding your home directory: %w; name the folder instead: 012 agent --install-skill path/to/skills/012", err)30+ }31+ return filepath.Join(home, ".claude", "skills", "012"), nil32+}33+34+// runAgent is 012 agent --skill (print the skill) or --install-skill35+// [--force] [dir].36+func runAgent(args []string, e env) error {37+ a, err := parseArgs(args, nil, []string{"skill", "install-skill", "force", "help"})38+ switch {39+ case err != nil:40+ return usageError(err.Error(), agentUsage)41+ case a.has("help"):42+ fmt.Fprintln(e.stdout, agentUsage)43+ return nil44+ case a.has("skill") && !a.has("install-skill") && len(a.pos) == 0:45+ _, err := fmt.Fprint(e.stdout, skillSource)46+ return err47+ case !a.has("install-skill") || a.has("skill") || len(a.pos) > 1:48+ return usageError("", agentUsage)49+ }50+ path, err := skillPath(a.pos)51+ if err != nil {52+ return err53+ }54+ wrote, err := installSkill(path, a.has("force"))55+ if err != nil {56+ return err57+ }58+ if wrote {59+ fmt.Fprintln(e.stdout, "Wrote", path)60+ } else {61+ fmt.Fprintln(e.stdout, path, "is up to date")62+ }63+ fmt.Fprintln(e.stdout, "Claude Code finds it in the next session; /skills lists it.")64+ return nil65+}66+67+// skillPath is the SKILL.md to write: in the folder given (a file named68+// there, if it ends in .md), or in ~/.claude/skills/012.69+func skillPath(given []string) (string, error) {70+ dir := ""71+ if len(given) == 1 {72+ p, err := filepath.Abs(given[0])73+ if err != nil {74+ return "", err75+ }76+ if strings.EqualFold(filepath.Ext(p), ".md") {77+ return p, nil78+ }79+ dir = p80+ } else {81+ var err error82+ if dir, err = skillHome(); err != nil {83+ return "", err84+ }85+ }86+ return filepath.Join(dir, "SKILL.md"), nil87+}88+89+// installSkill writes the skill to path, creating its folder. A file90+// already there is replaced only with force, unless it's this skill;91+// wrote is false then.92+func installSkill(path string, force bool) (wrote bool, err error) {93+ old, err := os.ReadFile(path)94+ switch {95+ case err == nil && bytes.Equal(old, []byte(skillSource)):96+ return false, nil97+ case err == nil && !force:98+ return false, fmt.Errorf("%s exists and isn't this 012's skill; --force replaces it", path)99+ case err != nil && !errors.Is(err, fs.ErrNotExist):100+ return false, err101+ }102+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {103+ return false, err104+ }105+ return true, os.WriteFile(path, []byte(skillSource), 0o644)106+}@@ -0,0 +1,45 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+func TestAgentSkill(t *testing.T) {11+ e, out, _ := scriptEnv(t)12+ if code, err := status(e, "agent", "--skill"); code != 0 || !strings.HasPrefix(out.String(), "---\nname: 012\n") {13+ t.Fatalf("--skill: %d %v %.40q", code, err, out)14+ }15+ home := filepath.Join(t.TempDir(), "skills", "012")16+ old := skillHome17+ skillHome = func() (string, error) { return home, nil }18+ t.Cleanup(func() { skillHome = old })19+20+ out.Reset()21+ if code, err := status(e, "agent", "--install-skill"); code != 0 || !strings.Contains(out.String(), "Wrote "+filepath.Join(home, "SKILL.md")) {22+ t.Fatalf("install: %d %v %q", code, err, out)23+ }24+ out.Reset()25+ if status(e, "agent", "--install-skill"); !strings.Contains(out.String(), "is up to date") {26+ t.Errorf("again: %q", out)27+ }28+ os.WriteFile(filepath.Join(home, "SKILL.md"), []byte("mine"), 0o644)29+ if _, err := status(e, "agent", "--install-skill"); err == nil || !strings.Contains(err.Error(), "--force replaces it") {30+ t.Errorf("over another file: %v", err)31+ }32+ if code, err := status(e, "agent", "--install-skill", "--force"); code != 0 {33+ t.Error(err)34+ }35+ dir := filepath.Join(t.TempDir(), "project", ".claude", "skills", "012")36+ if code, err := status(e, "agent", "--install-skill", dir); code != 0 {37+ t.Fatal(err)38+ }39+ if data, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")); string(data) != skillSource {40+ t.Errorf("project skill not written")41+ }42+ if code, _ := status(e, "agent"); code != 2 {43+ t.Errorf("agent alone: status %d", code)44+ }45+}@@ -0,0 +1,49 @@
1+package main2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/012/internal/headless"7+)8+9+// 012 describe: what a workbook holds, for agents and people deciding10+// what to read (see docs/agents/README.md).11+12+const describeUsage = "usage: 012 describe file.012 [--format text|json|nuon] [--notebooks] [--jev] [--trust]"13+14+// runDescribe is 012 describe: its sheets, used ranges, guessed header15+// rows, names, tables, regions, notebooks and charts.16+func runDescribe(args []string, e env) error {17+ a, err := parseArgs(args, []string{"format"}, append([]string{"help"}, evalFlags...))18+ switch {19+ case err != nil:20+ return usageError(err.Error(), describeUsage)21+ case a.has("help"):22+ fmt.Fprintln(e.stdout, describeUsage)23+ return nil24+ case len(a.pos) != 1:25+ return usageError("", describeUsage)26+ }27+ format, err := resultFormat(a)28+ if err != nil {29+ return usageError(err.Error(), describeUsage)30+ }31+ f, err := headless.Open(a.pos[0], false)32+ if err != nil {33+ return err34+ }35+ failed := evaluate(f, a, e)36+ if err := failed.fatal(); err != nil {37+ return err38+ }39+ d := headless.Describe(f.Book)40+ if format == "text" {41+ err = headless.WriteDescription(e.stdout, d)42+ } else {43+ err = headless.Encode(e.stdout, format, d)44+ }45+ if err != nil {46+ return err47+ }48+ return failed.err()49+}@@ -19,7 +19,7 @@ import (
1919 const (2020 diffUsage = "usage: 012 diff a.012 b.012 [--format text|json|nuon] [--color auto|always|never]\n" +2121 " 012 diff --textconv file.012"22- mergeUsage = "usage: 012 merge-driver base.012 ours.012 theirs.012 [path]"22+ mergeUsage = "usage: 012 merge-driver base.012 ours.012 theirs.012 [path] [--format text|json|nuon]"2323 )2424 2525 // runDiff is 012 diff: exit status 0 when the workbooks are the same, 1@@ -156,10 +156,13 @@ func textconv(path string, w io.Writer) error {
156156 // as a conflict to resolve; on trouble it leaves ours as it was and157157 // exits 2.158158 func runMergeDriver(args []string, e env) error {159- a, err := parseArgs(args, nil, []string{"help"})159+ a, err := parseArgs(args, []string{"format"}, []string{"help"})160+ format, ferr := resultFormat(a)160161 switch {161162 case err != nil:162163 return usageError(err.Error(), mergeUsage)164+ case ferr != nil:165+ return usageError(ferr.Error(), mergeUsage)163166 case a.has("help"):164167 fmt.Fprintln(e.stdout, mergeUsage)165168 return nil@@ -188,6 +191,11 @@ func runMergeDriver(args []string, e env) error {
188191 return &exitError{code: 2, err: err}189192 }190193 }194+ if format != "text" {195+ if err := headless.Encode(e.stdout, format, mergeResult(conflicts)); err != nil {196+ return &exitError{code: 2, err: err}197+ }198+ }191199 if len(conflicts) == 0 {192200 return nil193201 }@@ -207,3 +215,22 @@ func fileMode(path string) fs.FileMode {
207215 }208216 return st.Mode().Perm()209217 }218+219+// mergeConflict is a conflict as merge-driver --format json lists it.220+type mergeConflict struct {221+ Where string `json:"where"`222+ Field string `json:"field"`223+ Base string `json:"base"`224+ Ours string `json:"ours"`225+ Theirs string `json:"theirs"`226+ What string `json:"what"`227+}228+229+// mergeResult is what merge-driver --format json writes.230+func mergeResult(conflicts []diff.Conflict) map[string]any {231+ out := []mergeConflict{}232+ for _, c := range conflicts {233+ out = append(out, mergeConflict{c.Where, c.Field, c.Base, c.Ours, c.Theirs, c.What})234+ }235+ return map[string]any{"conflicts": out}236+}@@ -3,6 +3,7 @@ package main
33 import (44 "context"55 "fmt"6+ "io"67 "os"78 "path/filepath"89 "strings"@@ -12,13 +13,13 @@ import (
1213 "github.com/FelineStateMachine/012/internal/sheet"1314 )1415 15-const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite [ref] [--format kind] [--table name] [--notebooks] [--jev] [--trust]"16+const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite|html [ref] [--format kind] [--table name] [--chart n|title] [--report json|nuon] [--notebooks] [--jev] [--trust]"1617 1718 // runExport is 012 export: a sheet, a range or (to formats holding1819 // several sheets) the whole workbook written as File > Download writes1920 // it.2021 func runExport(args []string, e env) error {21- a, err := parseArgs(args, []string{"format", "table"}, append([]string{"help"}, evalFlags...))22+ a, err := parseArgs(args, []string{"format", "table", "chart", "report"}, append([]string{"help"}, evalFlags...))2223 switch {2324 case err != nil:2425 return usageError(err.Error(), exportUsage)@@ -33,6 +34,13 @@ func runExport(args []string, e env) error {
3334 if err != nil {3435 return usageError(err.Error(), exportUsage)3536 }37+ report := strings.ToLower(a.flags["report"])38+ if a.has("report") && report != "json" && report != "nuon" {39+ return usageError("--report "+report+": json or nuon", exportUsage)40+ }41+ if a.has("chart") && k != fileio.HTML {42+ return usageError("--chart writes a web page: name an .html file", exportUsage)43+ }3644 if same(src, out) {3745 return fmt.Errorf("%s is the workbook being exported: name another file", out)3846 }@@ -52,20 +60,37 @@ func runExport(args []string, e env) error {
5260 if err := failed.fatal(); err != nil {5361 return err5462 }63+ if a.has("chart") {64+ if err := exportChart(out, t, a.flags["chart"]); err != nil {65+ return err66+ }67+ return exportReport(e, report, headless.ExportResult{File: out, Format: "html", Notes: []string{}}, failed)68+ }5569 snap := exportSnap(t, k, ref == "")5670 res, err := fileio.Export(context.Background(), out, k, snap, fileio.ExportOptions{Table: a.flags["table"]})5771 if err != nil {5872 return fmt.Errorf("%s: %w", out, err)5973 }60- for _, n := range res.Notes {61- fmt.Fprintln(e.stderr, "012: note: "+n)74+ return exportReport(e, report, headless.ExportResult{File: out, Format: strings.ToLower(k.String()), Rows: res.Rows,75+ Notes: append([]string{}, res.Notes...)}, failed)76+}77+78+// exportReport says what was written: its notes on standard error, or79+// with --report the whole result on standard output.80+func exportReport(e env, report string, res headless.ExportResult, failed evalFailures) error {81+ if report == "" {82+ for _, n := range res.Notes {83+ fmt.Fprintln(e.stderr, "012: note: "+n)84+ }85+ } else if err := headless.Encode(e.stdout, report, res); err != nil {86+ return err6287 }6388 return failed.err()6489 }6590 6691 // exportKind is the format to write: --format's, or out's extension's.6792 func exportKind(out, named string) (fileio.Kind, error) {68- k, ok := fileio.KindOf(out)93+ k, ok := fileio.ExportKindOf(out)6994 if named != "" {7095 k, ok = fileio.KindNamed(named)7196 if !ok {@@ -115,3 +140,21 @@ func same(a, b string) bool {
115140 sb, err2 := os.Stat(b)116141 return err1 == nil && err2 == nil && os.SameFile(sa, sb)117142 }143+144+// exportChart is 012 export --chart: one chart of the target's sheet as145+// a web page of its own, found by its number (1 is the first) or title.146+func exportChart(out string, t headless.Target, which string) error {147+ i, err := headless.FindChart(t.Sheet, which)148+ if err != nil {149+ return err150+ }151+ c, _ := fileio.ChartSnap(t.Sheet, i)152+ err = headless.WriteAtomic(out, 0o644, func(w io.Writer) error {153+ _, err := fileio.WriteHTML(w, fileio.HTMLPage{Title: headless.ChartTitle(c.Chart), Chart: &c})154+ return err155+ })156+ if err != nil {157+ return fmt.Errorf("%s: %w", out, err)158+ }159+ return nil160+}@@ -0,0 +1,72 @@
1+package main2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// chartFile writes a workbook of two columns and a chart titled Sales.13+func chartFile(t *testing.T, dir string) string {14+ t.Helper()15+ w := sheet.NewBook()16+ s := w.Sheet(0)17+ for i, in := range []string{"Month", "Jan", "Feb"} {18+ s.Set(sheet.Addr{Row: i}, in)19+ }20+ for i, in := range []string{"Sales", "10", "20"} {21+ s.Set(sheet.Addr{Col: 1, Row: i}, in)22+ }23+ s.AddChart(sheet.Chart{Type: sheet.ChartLine, Data: sheet.Rect{To: sheet.Addr{Col: 1, Row: 2}}, Header: true, Labels: true,24+ Title: "Sales", At: sheet.Addr{Col: 3}, W: 30, H: 10})25+ path := filepath.Join(dir, "book.012")26+ f, err := os.Create(path)27+ if err != nil {28+ t.Fatal(err)29+ }30+ defer f.Close()31+ if err := w.Write(f); err != nil {32+ t.Fatal(err)33+ }34+ return path35+}36+37+func TestExportHTML(t *testing.T) {38+ e, _, _ := scriptEnv(t)39+ dir := t.TempDir()40+ path := chartFile(t, dir)41+ out := filepath.Join(dir, "book.html")42+ if code, err := status(e, "export", path, out); code != 0 {43+ t.Fatal(err)44+ }45+ data, _ := os.ReadFile(out)46+ if page := string(data); !strings.Contains(page, `data-a="B3" class="r">20<`) || !strings.Contains(page, "<polyline") {47+ t.Errorf("page lacks the cells or the chart")48+ }49+ chart := filepath.Join(dir, "chart.html")50+ for _, which := range []string{"1", "sales"} {51+ os.Remove(chart)52+ if code, err := status(e, "export", path, chart, "--chart", which); code != 0 {53+ t.Fatal(err)54+ }55+ data, _ := os.ReadFile(chart)56+ if page := string(data); !strings.Contains(page, "<title>Sales</title>") || strings.Contains(page, "<table") {57+ t.Errorf("--chart %s: not the chart alone", which)58+ }59+ }60+ for _, c := range []struct {61+ args []string62+ want string63+ }{64+ {[]string{path, chart, "--chart", "2"}, "Sheet1 has 1 chart: chart 2 isn't one"},65+ {[]string{path, chart, "--chart", "Costs"}, `no chart titled "Costs" (charts: Sales)`},66+ {[]string{path, filepath.Join(dir, "x.csv"), "--chart", "1"}, "--chart writes a web page"},67+ } {68+ if _, err := status(e, append([]string{"export"}, c.args...)...); err == nil || !strings.Contains(err.Error(), c.want) {69+ t.Errorf("export %v: %v, want %q", c.args, err, c.want)70+ }71+ }72+}@@ -80,8 +80,10 @@ func usage() error {
8080 " 012 nu [flags] [file]: the workbook's nushell notebook (see docs/nushell/notebooks.md)\n" +8181 " 012 nu --module | --install-module [--force] [path]: the nushell module with sheet (see docs/nushell/README.md)\n" +8282 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +83- " 012 get|set|recalc|export file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +83+ " 012 get|set|recalc|export|describe file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +8484 " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +85+ " 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]: an MCP server on the workbook (see docs/agents/mcp.md)\n" +86+ " 012 agent --skill | --install-skill [--force] [dir]: the Claude Code skill (see docs/agents/README.md)\n" +8587 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +8688 " 012 version")8789 }@@ -90,8 +92,8 @@ func usage() error {
9092 // opens as ./name.9193 var subcommands = map[string]func([]string, env) error{9294 "config": runConfig, "serve": runServe,93- "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport,94- "diff": runDiff, "merge-driver": runMergeDriver,95+ "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport, "describe": runDescribe,96+ "diff": runDiff, "merge-driver": runMergeDriver, "agent": runAgent, "mcp": runMCP,9597 }9698 9799 func run(args []string, e env) error {@@ -99,7 +101,7 @@ func run(args []string, e env) error {
99101 return subcommands[args[0]](args[1:], e)100102 }101103 if len(args) > 0 && (args[0] == "version" || args[0] == "--version") {102- return runVersion(e)104+ return runVersion(args[1:], e)103105 }104106 if len(args) > 0 && args[0] == "nu" && isModuleCommand(args[1:]) {105107 return runNuModule(args[1:], e)@@ -0,0 +1,86 @@
1+package main2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "os"8+ "os/signal"9+ "sync"10+11+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"12+13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/headless"15+ "github.com/FelineStateMachine/012/internal/jev"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// 012 mcp: a Model Context Protocol server over standard input and21+// output on one workbook file (see docs/agents/mcp.md).22+23+const mcpUsage = "usage: 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]"24+25+// runMCP is 012 mcp: it serves until the client closes standard input.26+// Nothing runs a program or reaches the network unless its flag says:27+// --notebooks offers run_notebook_cell, --jev answers JEV functions.28+func runMCP(args []string, e env) error {29+ a, err := parseArgs(args, nil, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})30+ switch {31+ case err != nil:32+ return usageError(err.Error(), mcpUsage)33+ case a.has("help"):34+ fmt.Fprintln(e.stdout, mcpUsage)35+ return nil36+ case len(a.pos) != 1:37+ return usageError("", mcpUsage)38+ case a.has("trust") && !a.has("notebooks"):39+ return usageError("--trust goes with --notebooks", mcpUsage)40+ }41+ path := a.pos[0]42+ if _, err := headless.Open(path, true); err != nil {43+ return err44+ }45+ cfg, err := loadConfig(e, nil)46+ if err != nil {47+ return err48+ }49+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)50+ defer stop()51+ b := &mcp.FileBackend{Path: path, Save: func(f *headless.File) error { return save(f, e) }}52+ if a.has("jev") {53+ b.Prepare = jevPreparer(cfg, e)54+ }55+ o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force")}56+ if a.has("notebooks") && !a.has("read-only") {57+ o.Notebooks = &headless.NotebookOptions{Runner: e.nuRunner(), Timeout: cfg.Duration("nu-timeout"), NuConfig: cfg.Bool("nu-config")}58+ o.MayRun = func(w *sheet.Workbook) error {59+ return mayRun(&headless.File{Path: path, Book: w}, cfg, a.has("trust"))60+ }61+ }62+ t := &sdk.IOTransport{Reader: io.NopCloser(e.stdin), Writer: nopWriteCloser{e.stdout}}63+ return mcp.New(b, o).Run(ctx, t)64+}65+66+// jevPreparer answers a workbook's JEV functions each time it's opened,67+// connecting once and keeping the answers, so each question is asked68+// once a session.69+func jevPreparer(cfg *config.Config, e env) func(context.Context, *headless.File) error {70+ var once sync.Once71+ var client jev.Client72+ var connErr error73+ cache := jev.NewCache()74+ return func(ctx context.Context, f *headless.File) error {75+ once.Do(func() { client, connErr = jevClient(ctx, cfg, e) })76+ if connErr != nil {77+ return connErr78+ }79+ headless.AnswerJEVFrom(ctx, f.Book, client, cache, jevTimeout)80+ return nil81+ }82+}83+84+type nopWriteCloser struct{ io.Writer }85+86+func (nopWriteCloser) Close() error { return nil }@@ -0,0 +1,50 @@
1+package main2+3+import (4+ "context"5+ "io"6+ "path/filepath"7+ "slices"8+ "testing"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+)12+13+// TestMCPOverStdio runs 012 mcp on pipes standing for standard input14+// and output, as a host runs it, and lists its tools.15+func TestMCPOverStdio(t *testing.T) {16+ e, _, _ := scriptEnv(t)17+ path := filepath.Join(t.TempDir(), "book.012")18+ status(e, "set", path, "A1", "1")19+ inR, inW := io.Pipe()20+ outR, outW := io.Pipe()21+ e.stdin, e.stdout = inR, outW22+ done := make(chan error, 1)23+ go func() {24+ done <- run([]string{"mcp", path, "--read-only"}, e)25+ outW.Close()26+ }()27+ ctx := context.Background()28+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, &sdk.IOTransport{Reader: outR, Writer: inW}, nil)29+ if err != nil {30+ t.Fatal(err)31+ }32+ var names []string33+ for tl, err := range cs.Tools(ctx, nil) {34+ if err != nil {35+ t.Fatal(err)36+ }37+ names = append(names, tl.Name)38+ }39+ if !slices.Contains(names, "read_range") || slices.Contains(names, "write_cells") {40+ t.Errorf("tools of a read-only server: %v", names)41+ }42+ cs.Close()43+ inW.Close()44+ if err := <-done; err != nil {45+ t.Errorf("012 mcp: %v", err)46+ }47+ if code, _ := status(e, "mcp", path, "--trust"); code != 2 {48+ t.Errorf("--trust without --notebooks: status %d", code)49+ }50+}@@ -0,0 +1,76 @@
1+package main2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/012/internal/headless"11+)12+13+// TestSetDryRun prints the change as 012 diff would and leaves the14+// file as it was.15+func TestSetDryRun(t *testing.T) {16+ e, out, _ := scriptEnv(t)17+ path := filepath.Join(t.TempDir(), "book.012")18+ status(e, "set", path, "A1", "1")19+ before, _ := os.ReadFile(path)20+ out.Reset()21+ if code, err := status(e, "set", path, "A1", "2", "B1", "=A1*2", "--dry-run"); code != 0 {22+ t.Fatal(err)23+ }24+ if got := out.String(); !strings.Contains(got, "Sheet1!A1 input 1 → 2") || !strings.Contains(got, "Sheet1!B1 input + =A1*2") {25+ t.Errorf("dry run printed %q", got)26+ }27+ if after, _ := os.ReadFile(path); string(after) != string(before) {28+ t.Errorf("dry run saved")29+ }30+ out.Reset()31+ if code, err := status(e, "set", path, "A1", "3", "--format", "json"); code != 0 {32+ t.Fatal(err)33+ }34+ var res headless.SetResult35+ if err := json.Unmarshal(out.Bytes(), &res); err != nil {36+ t.Fatalf("%v: %s", err, out)37+ }38+ if !res.Saved || len(res.Changes) == 0 || res.Changes[0].Item != "A1" || string(res.Changes[0].New) != `"3"` {39+ t.Errorf("set result %+v", res)40+ }41+ // A new file's dry run lists what it would hold, and makes no file.42+ fresh := filepath.Join(t.TempDir(), "new.012")43+ out.Reset()44+ status(e, "set", fresh, "A1", "x", "--dry-run", "--format", "nuon")45+ if _, err := os.Stat(fresh); err == nil || !strings.Contains(out.String(), "saved: false") {46+ t.Errorf("dry run on a new file: %s", out)47+ }48+}49+50+// TestResultFormats checks the other commands' --format json results.51+func TestResultFormats(t *testing.T) {52+ e, out, _ := scriptEnv(t)53+ dir := t.TempDir()54+ path := filepath.Join(dir, "book.012")55+ status(e, "set", path, "A1", "Item", "B1", "Price", "A2", "Pen", "B2", "=1/0")56+ for _, c := range []struct {57+ args []string58+ code int59+ want string60+ }{61+ {[]string{"recalc", path, "--format", "json"}, 1, `"value": "#DIV/0!"`},62+ {[]string{"describe", path, "--format", "json"}, 0, `"columns": [`},63+ {[]string{"describe", path}, 0, "Sheet1 (shown) A1:B2, 2 rows x 2 columns, 4 cells, 1 formula (1 showing errors)"},64+ {[]string{"export", path, filepath.Join(dir, "out.csv"), "--report", "json"}, 0, `"format": "csv"`},65+ {[]string{"version", "--format", "json"}, 0, `"go": "go`},66+ } {67+ out.Reset()68+ code, err := status(e, c.args...)69+ if code != c.code || !strings.Contains(out.String(), c.want) {70+ t.Errorf("%v: %d %v %s, want %q", c.args, code, err, out, c.want)71+ }72+ }73+ if code, _ := status(e, "describe", path, "--format", "csv"); code != 2 {74+ t.Errorf("describe --format csv: status %d", code)75+ }76+}@@ -7,6 +7,7 @@ import (
77 "slices"88 "strings"99 10+ "github.com/FelineStateMachine/012/internal/diff"1011 "github.com/FelineStateMachine/012/internal/headless"1112 "github.com/FelineStateMachine/012/internal/notebook"1213 )@@ -17,8 +18,8 @@ import (
1718 1819 const (1920 getUsage = "usage: 012 get file.012 [ref] [--format text|csv|tsv|json|nuon] [--input] [--no-header] [--notebooks] [--jev] [--trust]"20- setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force]"21- recalcUsage = "usage: 012 recalc file.012 [--notebooks] [--jev] [--trust]"21+ setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force] [--dry-run] [--format text|json|nuon]"22+ recalcUsage = "usage: 012 recalc file.012 [--format text|json|nuon] [--notebooks] [--jev] [--trust]"2223 )2324 2425 // evalFlags are the flags that let a command run a workbook's notebook@@ -63,9 +64,10 @@ func runGet(args []string, e env) error {
6364 return failed.err()6465 }6566 66-// runSet is 012 set: type entries into cells and save.67+// runSet is 012 set: type entries into cells and save, or with68+// --dry-run print what would change, as 012 diff would, and not save.6769 func runSet(args []string, e env) error {68- a, err := parseArgs(args, nil, []string{"force", "help"})70+ a, err := parseArgs(args, []string{"format"}, []string{"force", "dry-run", "help"})6971 switch {7072 case err != nil:7173 return usageError(err.Error(), setUsage)@@ -75,6 +77,10 @@ func runSet(args []string, e env) error {
7577 case len(a.pos) < 3 || len(a.pos)%2 == 0:7678 return usageError("", setUsage)7779 }80+ format, err := resultFormat(a)81+ if err != nil {82+ return usageError(err.Error(), setUsage)83+ }7884 f, err := headless.Open(a.pos[0], true)7985 if err != nil {8086 return err@@ -87,16 +93,55 @@ func runSet(args []string, e env) error {
8793 if err != nil {8894 return fmt.Errorf("%w; %s is unchanged", err, f.Path)8995 }90- for _, w := range warnings {91- fmt.Fprintln(e.stderr, "012: warning: "+w)96+ res := headless.SetResult{File: f.Path, Warnings: warnings, Changes: []headless.Change{}}97+ if format == "text" {98+ for _, w := range warnings {99+ fmt.Fprintln(e.stderr, "012: warning: "+w)100+ }101+ }102+ if format != "text" || a.has("dry-run") {103+ changes, err := f.Changes()104+ if err != nil {105+ return err106+ }107+ if format == "text" {108+ color, _ := useColor("", e)109+ return diff.WriteChanges(e.stdout, changes, "text", color)110+ }111+ res.Changes = headless.Changes(changes)112+ }113+ if !a.has("dry-run") {114+ if err := save(f, e); err != nil {115+ return err116+ }117+ res.Saved = true118+ }119+ if format == "text" {120+ return nil121+ }122+ if res.Warnings == nil {123+ res.Warnings = []string{}124+ }125+ return headless.Encode(e.stdout, format, res)126+}127+128+// resultFormat is --format's form for a command's result: text, json129+// or nuon.130+func resultFormat(a cliArgs) (string, error) {131+ switch f := strings.ToLower(a.flags["format"]); f {132+ case "", "text":133+ return "text", nil134+ case "json", "nuon":135+ return f, nil136+ default:137+ return "", errors.New("--format " + f + ": text, json or nuon")92138 }93- return save(f, e)94139 }95140 96141 // runRecalc is 012 recalc: recalculate, save, and list the cells whose97142 // formulas show errors, exiting 1 when there are any.98143 func runRecalc(args []string, e env) error {99- a, err := parseArgs(args, nil, append([]string{"help"}, evalFlags...))144+ a, err := parseArgs(args, []string{"format"}, append([]string{"help"}, evalFlags...))100145 switch {101146 case err != nil:102147 return usageError(err.Error(), recalcUsage)@@ -106,6 +151,10 @@ func runRecalc(args []string, e env) error {
106151 case len(a.pos) != 1:107152 return usageError("", recalcUsage)108153 }154+ format, err := resultFormat(a)155+ if err != nil {156+ return usageError(err.Error(), recalcUsage)157+ }109158 f, err := headless.Open(a.pos[0], false)110159 if err != nil {111160 return err@@ -118,6 +167,14 @@ func runRecalc(args []string, e env) error {
118167 if err := save(f, e); err != nil {119168 return err120169 }170+ if format != "text" {171+ res := headless.RecalcResult{File: f.Path, Errors: headless.Problems(problems), Circular: circular,172+ NotebookFailures: append([]string{}, failed.cells...)}173+ if err := headless.Encode(e.stdout, format, res); err != nil {174+ return err175+ }176+ return problemStatus(problems, circular, failed)177+ }121178 return reportProblems(e.stdout, problems, circular, failed)122179 }123180 @@ -149,6 +206,12 @@ func reportProblems(w io.Writer, problems []headless.Problem, circular bool, fai
149206 if circular {150207 fmt.Fprintln(w, "circular reference: a formula reads its own cell, directly or through others")151208 }209+ return problemStatus(problems, circular, failed)210+}211+212+// problemStatus is recalc's exit status: 1 when cells show errors or a213+// notebook cell failed.214+func problemStatus(problems []headless.Problem, circular bool, failed evalFailures) error {152215 if len(problems) > 0 || circular {153216 return &exitError{code: 1, err: fmt.Errorf("%d %s showing errors", len(problems), plural(len(problems), "cell", "cells"))}154217 }@@ -0,0 +1,95 @@
1+---2+name: 0123+description: Read, change, check and share 012 spreadsheet workbooks (.012 files) from the shell with the 012 command. Use when a task involves a .012 file, or when a spreadsheet, table of numbers or chart should be built, inspected, recalculated or exported (CSV, XLSX, JSON, NUON, SQLite, HTML).4+---5+6+# Working with 012 workbooks7+8+012 is a terminal spreadsheet. Its `.012` files are workbooks of sheets,9+formulas, tables, charts and nushell notebooks. Work on them with the10+`012` command, never by editing the file's JSON: `012 set` checks every11+entry as the screen does and saves atomically, and its changes show in12+`012 diff` like a person's.13+14+## Look before you read15+16+```sh17+012 describe book.012 # sheets, used ranges, header rows, tables, names, charts, notebooks18+012 describe book.012 --format json # the same as JSON19+```20+21+Read what describe points at rather than whole sheets.22+23+## Read24+25+```sh26+012 get book.012 B7 # one cell, as shown: $1,200.0027+012 get book.012 B7 --input # what was typed: =SUM(B2:B6)28+012 get book.012 'Q3 plan'!A1:F20 --format json # records named by the first row29+012 get book.012 Sales --format json # a table or named range by name30+012 get book.012 'Sales[Amount]' --format json # one column of a table31+```32+33+References are written as in formulas: `B7`, `A1:C9`, `A:A`, `Q3!B7`,34+`'Q3 plan'!A1:C9`, a named range, a table (`Sales`, `Sales[Amount]`) or a35+sheet name for the whole sheet. Without a sheet, the sheet shown when the36+file was saved is meant. `--no-header` names JSON columns by letter.37+38+## Change39+40+Preview first, then write:41+42+```sh43+012 set book.012 B7 '=SUM(B2:B6)' B8 1200 --dry-run # prints the change as 012 diff would; saves nothing44+012 set book.012 B7 '=SUM(B2:B6)' B8 1200 # types the entries, as one change, and saves45+012 set book.012 B7 '=SUM(B2:B6)' --format json # {file, saved, changes, warnings}46+```47+48+- Inputs are what a person types, in en-US form whatever the workbook's49+ locale: `1.5`, `=ROUND(A1, 2)`, `$1,200` (sets a currency format),50+ `12%`, `2026-09-29`. An empty input clears a cell.51+- Formulas are Google Sheets' (`SUM`, `XLOOKUP`, `FILTER`, `LET`,52+ `LAMBDA`, structured references like `Sales[Amount]`).53+- `set` refuses, and changes nothing, on the first entry a cell can't54+ take: a formula that doesn't parse, a validation rule, a protected55+ range (`--force` overrides only that), or a cell of a spill, pivot56+ table or notebook output. Read the message; it names the cell.57+- An input starting with `--` goes after `--`: `012 set book.012 -- A1 --`.58+59+## Check60+61+```sh62+012 recalc book.012 # recalculates, saves, lists cells showing errors; exit 1 if any63+012 recalc book.012 --format json # {file, errors: [{cell, value, why}], circular, notebook_failures}64+012 diff old.012 new.012 --format json65+```66+67+Run `012 recalc` after changing formulas and fix what it lists.68+69+## Share70+71+```sh72+012 export book.012 out.xlsx # every sheet, with formulas and formats73+012 export book.012 out.csv 'Q3'!A1:F20 # values as shown74+012 export book.012 report.html # a web page in 012's look, charts as SVG75+012 export book.012 chart.html --chart 1 # one chart alone, by number or title76+```77+78+## Safety79+80+Nothing runs a program or reaches the network unless a flag asks:81+`--notebooks` runs the workbook's nushell notebook cells (and82+`--trust` a workbook saved on another computer), `--jev` asks the JEV83+model service. Don't add them unless the user wants that.84+85+## Exit status86+87+0 done; 1 an error on standard error (or, for `recalc`, cells showing88+errors; for `diff`, the workbooks differ); 2 the command was used89+wrongly, with its usage. Every command's `--help` prints its usage.90+91+When the 012 MCP server (`012 mcp book.012`) is connected, its tools92+(describe, read_range, write_cells with dry_run, evaluate, list_errors)93+do the same with the same checks.94+95+The full reference: https://github.com/FelineStateMachine/012/blob/main/docs/agents/README.md@@ -4,6 +4,8 @@ import (
44 "fmt"55 "runtime"66 "runtime/debug"7+8+ "github.com/FelineStateMachine/012/internal/headless"79 )810 911 // version is the release this binary was built as, set by make dist@@ -37,7 +39,26 @@ func buildVersion() string {
3739 return v3840 }3941 40-func runVersion(e env) error {41- _, err := fmt.Fprintf(e.stdout, "012 %s %s %s/%s\n", buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)42+// versionResult is what 012 version --format json writes.43+type versionResult struct {44+ Version string `json:"version"`45+ Go string `json:"go"`46+ OS string `json:"os"`47+ Arch string `json:"arch"`48+}49+50+func runVersion(args []string, e env) error {51+ a, err := parseArgs(args, []string{"format"}, nil)52+ if err != nil || len(a.pos) > 0 {53+ return usageError("", "usage: 012 version [--format text|json|nuon]")54+ }55+ format, err := resultFormat(a)56+ if err != nil {57+ return usageError(err.Error(), "usage: 012 version [--format text|json|nuon]")58+ }59+ if format != "text" {60+ return headless.Encode(e.stdout, format, versionResult{buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH})61+ }62+ _, err = fmt.Fprintf(e.stdout, "012 %s %s %s/%s\n", buildVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)4263 return err4364 }@@ -13,7 +13,8 @@ sidebar_position: 1
1313 | [Files](files/README.md) | Import and download, Excel files, saving, the `.012` format, scripts, diff and merge in git |1414 | [The terminal](terminal/README.md) | Terminal features, themes, serving over SSH |1515 | [Nushell](nushell/README.md) | 012 in a pipeline, nushell notebooks, types, a cookbook |16-| [Reference](reference/README.md) | Keys, functions, configuration, the macro API, the command line |16+| [Agents](agents/README.md) | Agents working on workbooks: the commands and their JSON, the Claude Code skill |17+| [Reference](reference/README.md) | Keys, functions, configuration, the macro API, the command line, JSON output |1718 | [Contributing](contributing/README.md) | Architecture, extending, the UX bar, testing, limits, observability |1819 1920 The [roadmap](../ROADMAP.md) says what's ahead. The docs are Markdown that@@ -0,0 +1,90 @@
1+---2+title: "Agents"3+sidebar_position: 14+---5+6+# Agents7+8+Coding agents and assistants work on `.012` workbooks through the same9+operations people use: every change is typed into cells as the screen10+types it, checked by the same rules, made as one change and11+shown by [`012 diff`](../files/git.md#012-diff) like anyone's. Nothing12+agents do runs a program or reaches the network unless they were13+started with the flags that allow it, as for scripts.14+15+| Page | For |16+|---|---|17+| This page | Agents with a shell: the commands, their JSON, and the Claude Code skill |18+| [MCP server](mcp.md) | `012 mcp`, for any MCP host: tools, resources, prompts, and adding it to Claude Code and Claude Desktop |19+| [JSON output](../reference/json.md) | The schemas of what commands write with `--format json`, and what the MCP tools return |20+21+## With a shell22+23+Agents that can run commands use the ones [scripts](../files/scripts.md)24+use, in this order:25+26+```sh27+012 describe book.012 --format json # what's where28+012 get book.012 Sales --format json # read what describe pointed at29+012 set book.012 D2 '=B2*C2' D3 '=B3*C3' --dry-run # preview a change as a diff30+012 set book.012 D2 '=B2*C2' D3 '=B3*C3' # make it: one change, saved atomically31+012 recalc book.012 --format json # check no formula shows an error32+012 export book.012 report.html # share it as a web page33+```34+35+`012 describe` lists each sheet with its used range, the row that looks36+like its header and the column names in it, frozen panes, filters,37+tables, notebook outputs and linked files sent to it, and charts38+(numbered, as `012 export --chart` names them); pivot tables with their39+source; notebook tabs with their cells and whether each ran; and the40+workbook's named ranges:41+42+```43+Sales (shown) A1:F9, 9 rows x 6 columns, 54 cells, 9 formulas44+ header row 1: Date, Region, Units, Price, Total, Share45+ frozen 1 row, 0 columns46+ table Orders A1:F9 Date, Region, Units, Price, Total, Share47+ chart 1 Units by date column of A1:C9 at H248+Notebook (notebook) 2 cells49+ 1 code files = ls (ran)50+ 2 code $files | where size > 1kb (not run)51+names52+ Rate Sales!B1253+```54+55+A header row is guessed: the first row with contents, when every cell56+in it is text and the rows below hold numbers, dates, booleans or57+formulas, or when it's bold. `--format json` gives the same as the58+[schema](../reference/json.md#describe).59+60+`012 set --dry-run` checks the entries as `set` does (a formula that61+doesn't parse, a validation rule, a protected range stop it the same62+way) and prints what would change, as `012 diff` would, without saving;63+`--format json` gives the changes as records instead. Every command's64+flags are in [Command line](../reference/command-line.md).65+66+## The Claude Code skill67+68+012 carries a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills):69+a `SKILL.md` that tells Claude when to reach for `012` and how to use70+it well (describe first, preview with `--dry-run`, `recalc` after71+changing formulas, never edit the file's JSON by hand).72+73+```sh74+012 agent --install-skill # into ~/.claude/skills/012, for every project75+012 agent --install-skill .claude/skills/012 # into this project, to commit with it76+012 agent --skill # print it77+```78+79+Installing over a file that isn't this 012's skill asks for `--force`;80+installing the same skill again says it's up to date. A newer 012 may81+carry a newer skill: install it again after upgrading.82+83+## Trust84+85+Agents get no more than scripts do. A workbook's86+[notebook](../nushell/notebooks.md) cells run only with `--notebooks`,87+and then only when the workbook was saved on this computer or88+`--trust` says so; [JEV functions](../formulas/jev.md) ask the network89+only with `--jev`. Protected ranges refuse changes unless `--force` is90+given. Leave these flags to the person asking.@@ -0,0 +1,135 @@
1+---2+title: "MCP server"3+sidebar_position: 24+---5+6+# MCP server7+8+`012 mcp book.012` is a [Model Context Protocol](https://modelcontextprotocol.io)9+server on one workbook, over standard input and output, so any MCP host10+(Claude Code, Claude Desktop, editors) can read and change it. It is the11+same code as [the commands](README.md#with-a-shell): reads are12+`012 describe` and `012 get`, and every write is typed through the13+checks `012 set` makes, as one change, saved atomically at14+once.15+16+```mermaid17+flowchart LR18+ host[MCP host] -- "stdio: tools, resources, prompts" --> server["012 mcp book.012"]19+ server -- "open, read" --> file[(book.012)]20+ server -- "one change, Batch, atomic save" --> file21+ person["012 book.012, 012 set, git"] --> file22+```23+24+The file is opened afresh for every call, so what the screen, a script25+or git saved meanwhile is what the next call sees. A file that doesn't26+exist is an empty workbook, saved on the first change.27+28+## Adding it to a host29+30+Claude Code, for one workbook (`--scope project` writes `.mcp.json`31+to commit with the project; the default keeps it to you):32+33+```sh34+claude mcp add budget -- 012 mcp /path/to/budget.01235+claude mcp add --scope project budget -- 012 mcp budget.01236+```37+38+Claude Desktop, in `claude_desktop_config.json` (Settings > Developer >39+Edit Config), with the full paths, since Desktop doesn't start in your40+folder:41+42+```json43+{44+ "mcpServers": {45+ "budget": {46+ "command": "/usr/local/bin/012",47+ "args": ["mcp", "/Users/me/Documents/budget.012"]48+ }49+ }50+}51+```52+53+Other hosts take the same command and arguments.54+55+## Flags56+57+| Flag | Does |58+|---|---|59+| `--read-only` | Offers only the tools that read |60+| `--force` | Lets writes change [protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges), as `012 set --force` |61+| `--notebooks` | Offers `run_notebook_cell`, which runs a [notebook](../nushell/notebooks.md) cell with nu, if the workbook was saved on this computer |62+| `--trust` | With `--notebooks`, runs the cells of a workbook saved on another computer, and marks it trusted here |63+| `--jev` | Answers [JEV functions](../formulas/jev.md) over the network, each question once a session |64+65+Without them nothing runs a program or reaches the network: notebook66+outputs are read as the file keeps them, and JEV functions show `#N/A`.67+`shell = off` in the [configuration](../reference/config.md#shell) keeps68+notebook cells from running even with `--notebooks`.69+70+## Tools71+72+References are written as in formulas (`B7`, `Q3!A1:C9`,73+`'Q3 plan'!A:A`, a named range, a table, `Sales[Amount]`, a sheet's74+name), and inputs as a person types them, in en-US form. Every write75+takes `dry_run`, which returns the change without making it.76+77+| Tool | Does |78+|---|---|79+| `describe` | The workbook's sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |80+| `read_range` | A range's values as rows, its formulas by cell, and with `text` each cell as shown |81+| `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 |82+| `find` | Cells by what they show or, with `in_formulas`, their formulas' text, as Edit > Find |83+| `list_errors` | The formulas showing errors after recalculating, as `012 recalc` lists them, without saving |84+| `write_cells` | Entries typed into cells as one change, as `012 set` |85+| `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` |86+| `sort` | A range's rows sorted by columns, named by letter or header |87+| `filter` | A filter on a range by conditions (`gt`, `contains` and the rest) or values to hide, or the sheet's filter removed |88+| `create_chart` | A chart of a range, its type, title and place given or guessed as Insert > Chart guesses them |89+| `create_pivot` | A pivot table on a new sheet: rows, columns and summarized values by header |90+| `run_notebook_cell` | With `--notebooks`: one code cell run, its output kept and sent on to its sheet |91+92+A write returns `saved`, `changes` (as [`012 diff`](../reference/json.md#diff)93+lists them) and `warnings`; a refused one is an error naming the cell,94+with nothing changed. Every result's schema is in95+[JSON output](../reference/json.md#mcp-tools).96+97+## Views in the chat98+99+Hosts that support [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)100+(the `io.modelcontextprotocol/ui` extension) draw what `read_range`101+read and the chart `create_chart` made beside the result, as 012 draws102+them: the [web page](../files/README.md#web-pages) export's grid, with103+its fonts, colors, styles and charts as SVG, in the host's light or104+dark theme. Clicking a cell, or the arrow keys, puts the pointer on it105+and shows what was typed in it.106+107+Both tools name the view's resource, `ui://012/view`108+(`text/html;profile=mcp-app`), in their `_meta`; the host loads it into109+a sandboxed frame, and the result's `_meta` carries what to draw, at110+most 500 rows of a range. Hosts without the extension get the same111+results without it, and the page asks for nothing from the network.112+113+## Resources114+115+Hosts that let people attach data to a conversation list these; the116+scheme is `o12` since a URI's scheme starts with a letter, and a sheet's117+name is percent-encoded:118+119+| URI | Is |120+|---|---|121+| `o12://book/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |122+| `o12://book/table/Sales` | A table, header row included; each table is listed |123+| `o12://book/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |124+125+The list follows the workbook: a change that adds a sheet or table126+updates it, and hosts that listen are told.127+128+## Prompts129+130+| Prompt | Asks |131+|---|---|132+| `summarize_workbook` | What each sheet holds and what its numbers say, without changing anything |133+| `fix_errors` | The formulas showing errors explained, fixes checked with `evaluate` and previewed before they're made |134+| `add_column` | A column of formulas computing something beside a range (`range`, `column`) |135+| `chart` | The chart that best answers a question about a range (`range`, `question`) |@@ -27,9 +27,10 @@ it back in its format.
2727 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |2828 | JSON `.json`, `.ndjson`, `.jsonl` | A list of records (or records one after another, as NDJSON): keys become the header row, numbers, booleans and text keep their types, nested lists and records are their text | The sheet shown as a list of records named by its first row, numbers as numbers |2929 | Nushell `.nuon` | A nushell table with its types: file sizes, durations and dates become numbers in the Size, Duration and Date time formats; see [Nushell types](../nushell/types.md) | The sheet shown as a nushell table, first row as column names, types kept by the cells' formats |30+| Web page `.html` | | The sheet shown as a page in 012's look, for someone without 012: see [Web pages](#web-pages) |3031 31-CSV, TSV, JSON and NUON downloads hold the rows a filter shows, as32-Sheets copies a filtered range; XLSX keeps every row, the hidden ones32+CSV, TSV, JSON, NUON and HTML downloads hold the rows a filter shows,33+as Sheets copies a filtered range; XLSX keeps every row, the hidden ones3334 hidden.3435 3536 Formats other than XLSX import as one sheet named after the file (or the@@ -66,6 +67,30 @@ the context line says how many rows were left out, e.g. `only the first
6667 Data past the grid's edges is left out the same way. WK1 files keep their6768 own 8,192 by 256. See [Bounds of support](../contributing/limits.md#imports) for speeds.6869 70+## Web pages71+72+A web page download is one `.html` file that needs nothing else: its73+fonts (IBM Plex Mono), styles, charts and a few lines of script are in74+it, so it opens offline and can be mailed or put on any web server. It75+draws the sheet as 012 does: column letters and row numbers, each cell's76+text as the grid shows it at its column's width, with its bold, italic,77+alignment, wrapping, borders, merges, row heights, notes (the `▝` mark,78+the note as its tooltip), tables' header rows and bands, and what79+conditional formats and validation show (fills, color scales, data80+bars, icons, checkboxes, dropdown marks). Charts float over the cells81+at their corner, in their frames, drawn as SVG: shapes rather than82+pixels, sharp at any zoom. The colors are the 16 ANSI colors of 012's83+reference palettes, dark or light as the reader's system prefers.84+85+Clicking a cell, or moving with the arrow keys, puts the pointer on it:86+the name box shows its address and the formula bar what was typed in87+it, a formula rather than its value. A page draws at most 200,00088+cells, blank ones included, and says on standard error or the context89+line how many rows it left out.90+91+`012 export` also writes a range as a page, or one chart alone with92+`--chart` ([Scripts](scripts.md#export)).93+6994 ## More7095 7196 | Page | For |@@ -6,7 +6,8 @@ sidebar_position: 6
66 # Scripts77 88 Four commands read and change a `.012` workbook without the screen, for9-shell scripts, cron jobs, Makefiles and nushell pipelines:9+shell scripts, cron jobs, Makefiles, nushell pipelines and10+[agents](../agents/README.md), which also have `012 describe`:1011 1112 ```sh1213 012 get budget.012 B7 # a cell's value, as the sheet shows it@@ -97,6 +98,18 @@ An entry a rule only marks invalid is set, with a warning on standard
9798 error. An input that starts with `--` (a flag's shape) comes after `--`:9899 `012 set budget.012 -- A1 --`.99100 101+`--dry-run` checks the entries the same way but saves nothing, and102+prints what would change as [`012 diff`](git.md#012-diff) would:103+104+```105+$ 012 set budget.012 B7 '=SUM(A1:A6)' B8 1200 --dry-run106+Sheet1!B7 input "=SUM(A1:A5)" → "=SUM(A1:A6)"107+Sheet1!B8 input + 1200108+```109+110+`--format json` writes the result, the changes as records, instead111+([JSON output](../reference/json.md#set)).112+100113 ## recalc101114 102115 `012 recalc file` recomputes every formula, saves the file (leaving it@@ -109,14 +122,24 @@ Q3!D2 #N/A JEV functions need --jev and an API key to be answered
109122 ```110123 111124 It exits 1 when any cell shows an error or a formula reads its own cell,112-so `012 recalc budget.012 && deploy` stops on a broken sheet.125+so `012 recalc budget.012 && deploy` stops on a broken sheet. `--format126+json` writes the list as [JSON](../reference/json.md#recalc).113127 114128 ## export115129 116130 `012 export file out [ref]` writes the workbook in another format, as117131 File > Download does: the format by `out`'s extension or `--format`118-(`csv`, `tsv`, `xlsx`, `sqlite`, `json`, `nuon`). XLSX gets every sheet;119-the others get the sheet shown, or `ref`'s range or sheet. SQLite writes132+(`csv`, `tsv`, `xlsx`, `sqlite`, `json`, `nuon`, `html`). XLSX gets every133+sheet; the others get the sheet shown, or `ref`'s range or sheet. A134+[web page](README.md#web-pages) of a range draws the charts whose corner135+is in it; `--chart` writes one chart alone, found by its number on the136+sheet (1 for the first) or its title:137+138+```sh139+012 export budget.012 q3.html 'Q3 plan'!A1:F20140+012 export budget.012 spending.html Q3 --chart 'Spending by month'141+```142+ SQLite writes120143 a table named by `--table`, replacing one of that name. Formulas are121144 written as their values where the format has no formulas, and a note on122145 standard error says how many. CSV, TSV, JSON and NUON leave out the rows@@ -150,5 +173,5 @@ things need more than the file:
150173 network, and show `#N/A` here unless `--jev` asks it, with the API key151174 from the environment, the credential store or `jev-api-key-command`.152175 153-`get`, `recalc` and `export` take these flags; `set` computes values only154-to check entries.176+`get`, `recalc`, `export` and `describe` take these flags; `set`177+computes values only to check entries.@@ -12,3 +12,4 @@ sidebar_position: 1
1212 | [Configuration](config.md) | The config file, `012 config` and every option, generated from the registry |1313 | [Macro scripting API](macro-api.md) | The functions and command ids macros can use |1414 | [Command line](command-line.md) | Every command, its flags and exit statuses |15+| [JSON output](json.md) | The schemas of what commands write with `--format json` |@@ -25,18 +25,21 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
2525 | Command | Does |2626 |---|---|2727 | `012 get file.012 [ref]` | Writes a cell's value, or a range or sheet as a table: [Scripts](../files/scripts.md#get) |28-| `012 set file.012 ref input [ref input ...]` | Types entries into cells and saves: [Scripts](../files/scripts.md#set) |28+| `012 set file.012 ref input [ref input ...]` | Types entries into cells and saves, or with `--dry-run` prints the change: [Scripts](../files/scripts.md#set) |2929 | `012 recalc file.012` | Recalculates, saves and lists the cells showing errors: [Scripts](../files/scripts.md#recalc) |3030 | `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |31+| `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |32+| `012 mcp file.012` | Serves the workbook to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |3133 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3234 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3335 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |3436 | `012 nu --module`, `012 nu --install-module [--force] [path]` | Prints or installs the nushell module: [Nushell](../nushell/README.md#install-the-sheet-command) |37+| `012 agent --skill`, `012 agent --install-skill [--force] [dir]` | Prints or installs the Claude Code skill: [Agents](../agents/README.md#the-claude-code-skill) |3538 | `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |36-| `012 version` | Prints the version |39+| `012 version [--format text\|json\|nuon]` | Prints the version |3740 38-`get`, `set`, `recalc`, `export`, `diff` and `merge-driver` take their39-flags anywhere after the command's name, and `--help` prints their41+`get`, `set`, `recalc`, `export`, `describe`, `mcp`, `diff`,42+`merge-driver` and `agent` take their flags anywhere after the command's name, and `--help` prints their4043 usage. `--` ends the flags, so what follows is taken as it is4144 (`012 set book.012 -- A1 --`), and an argument such as `-5` is never a4245 flag.@@ -47,10 +50,15 @@ flag.
4750 | `--input` | `get` | What was typed (a formula) rather than its value |4851 | `--no-header` | `get` | JSON and NUON columns named by their letters, the first row a record |4952 | `--force` | `set` | Sets cells in protected ranges |53+| `--dry-run` | `set` | Prints what would change, as `012 diff` would, and saves nothing |54+| `--format text\|json\|nuon` | `set`, `recalc`, `describe`, `merge-driver`, `version` | The result as text, or as [JSON or NUON](json.md) |55+| `--report json\|nuon` | `export` | Writes what was exported as [JSON or NUON](json.md#export) |5056 | `--format kind`, `--table name` | `export` | The format when the file's extension doesn't say; SQLite's table |51-| `--notebooks` | `get`, `recalc`, `export` | Runs the notebooks' cells first, when the file is trusted here |57+| `--chart n\|title` | `export` | One chart of the sheet as a web page, by its number or title |58+| `--notebooks` | `get`, `recalc`, `export`, `describe` | Runs the notebooks' cells first, when the file is trusted here |5259 | `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |53-| `--jev` | `get`, `recalc`, `export` | Asks JEV for the JEV functions' answers first |60+| `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |61+| `--jev` | `get`, `recalc`, `export`, `describe` | Asks JEV for the JEV functions' answers first |5462 | `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |5563 | `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |5664 @@ -0,0 +1,169 @@
1+---2+title: "JSON output"3+sidebar_position: 74+---5+6+# JSON output7+8+Commands without the screen write their results as JSON with9+`--format json`, or as NUON (the same values, as nushell reads them)10+with `--format nuon`; `012 export` writes to a file, so its result11+comes with `--report json`. The schemas below are stable: a later 01212+may add fields, but never renames or removes one, or changes its type.13+Errors are not JSON: they go to standard error as text, with the exit14+statuses in [Command line](command-line.md#exit-status).15+16+## get17+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.23+24+## set25+26+```json27+{28+ "file": "book.012",29+ "saved": true,30+ "changes": [31+ {"kind": "cell", "sheet": "Sheet1", "item": "B7", "field": "input", "old": "12", "new": "=SUM(B1:B6)"}32+ ],33+ "warnings": ["Sheet1!C2: Enter a number between 1 and 10"]34+}35+```36+37+| Field | Is |38+|---|---|39+| `file` | The workbook's path, as given |40+| `saved` | Whether the file was written: false with `--dry-run` |41+| `changes` | What changed, as [`diff`](#diff) lists it |42+| `warnings` | Entries a validation rule marks invalid but lets in |43+44+## recalc45+46+```json47+{48+ "file": "book.012",49+ "errors": [50+ {"cell": "Q3!B7", "sheet": "Q3", "addr": "B7", "value": "#DIV/0!", "why": "Division by zero in B6/C6", "jev": false}51+ ],52+ "circular": false,53+ "notebook_failures": []54+}55+```56+57+`errors` are the formulas showing errors, sheet by sheet and row by58+row; `why` is what the screen's context line says, or `""`; `jev` is59+set for a JEV function's error that `--jev` would answer. `circular` is60+set when a formula reads its own cell. `notebook_failures` are the61+lines `--notebooks` reported for cells that failed. The exit status is62+1 when there are errors, as without `--format`.63+64+## describe65+66+```json67+{68+ "sheets": [69+ {70+ "name": "Sales", "kind": "sheet", "shown": true, "hidden": false,71+ "used": "A1:F9", "rows": 9, "cols": 6, "cells": 54, "formulas": 9, "errors": 0,72+ "header_row": 1, "columns": ["Date", "Region", "Units", "Price", "Total", "Share"],73+ "frozen": [1, 0], "filter": "",74+ "tables": [{"name": "Orders", "range": "A1:F9", "columns": ["Date", "Region", "Units", "Price", "Total", "Share"]}],75+ "regions": [{"name": "files", "kind": "output", "range": "H1:J12"}],76+ "charts": [{"number": 1, "title": "Units by date", "type": "column", "data": "A1:C9", "at": "H2"}]77+ }78+ ],79+ "names": [{"name": "Rate", "range": "Sales!B12"}]80+}81+```82+83+| Field | Is |84+|---|---|85+| `kind` | `sheet`, `notebook` or `pivot` |86+| `shown`, `hidden` | The sheet shown when the file was saved; a hidden tab |87+| `used` | A1 to the last cell with contents, `""` for an empty sheet; `rows` and `cols` its size |88+| `cells`, `formulas`, `errors` | Cells with contents, formulas, and formulas showing errors; `formulas` and `errors` are -1 on sheets of over a million cells, which aren't counted |89+| `header_row`, `columns` | The row guessed to name the columns (1 for the first; 0 and `[]` when none) and its names, `""` for a blank cell |90+| `frozen` | Frozen rows and columns |91+| `filter` | The filtered range, or `""` |92+| `tables` | Named tables, header row included in `range` |93+| `regions` | Notebook outputs (`kind` `output`) and linked files (`linked`, with `file`) sent to the sheet, with the range their rows fill |94+| `charts` | Each chart's `number` (for `012 export --chart`), title (its type's name when it has none), `type`, `data` range and `at`, the cell under its top-left corner |95+| `pivot` | On a pivot table, `{"source": "Sales!A1:F9"}` |96+| `notebook_cells` | On a notebook, each cell: `number`, `kind` (`code` or `note`), `name` (its output's), `source`, and for code `state` (`ran`, `failed`, `not run`) and `error` |97+| `names` | Named ranges, `range` `#REF!` when its cells were deleted |98+99+## diff100+101+`012 diff a b --format json` writes a list of changes:102+103+| Field | Is |104+|---|---|105+| `kind` | `cell`, `sheet`, `region`, `table`, `layout` (another field of a sheet: widths, rules, charts), `name`, `macro`, `notebook` or `workbook` (its locale, its arithmetic) |106+| `sheet` | The sheet, by its name in the second workbook where it has one |107+| `item` | The cell, region, name, macro or field that changed |108+| `field` | What about it: for a cell `input`, `value`, `format` or `note`; for a sheet `added`, `removed`, `renamed` or `moved` |109+| `old`, `new` | Its values, `null` where it wasn't or isn't there |110+111+See [Diff and merge in git](../files/git.md#012-diff).112+113+## export114+115+`012 export file out --report json`:116+117+```json118+{"file": "out.csv", "format": "csv", "rows": 9, "notes": ["1 formula saved as values"]}119+```120+121+## merge-driver122+123+`012 merge-driver base ours theirs --format json` writes124+`{"conflicts": [...]}`, each with `where`, `field`, and the `base`,125+`ours` and `theirs` versions as text (`""` where it isn't there), and126+`what` for a conflict about a whole sheet. The merge and exit status127+are as without it.128+129+## MCP tools130+131+The [MCP server](../agents/mcp.md)'s tools return these, as structured132+content with the same JSON as text beside it. `describe` returns133+[describe's](#describe) schema, `list_errors` recalc's `errors` and134+`circular`.135+136+`read_range`, and the range resources:137+138+```json139+{140+ "range": "Sales!A1:C3", "rows": 3, "cols": 3,141+ "values": [["Region", "Units", "Price"], ["North", 3, 12.5], ["South", 5, null]],142+ "text": [["Region", "Units", "Price"], ["North", "3", "$12.50"], ["South", "5", ""]],143+ "formulas": {"C3": "=IF(B3>4, \"\", 9.75)"},144+ "truncated": false145+}146+```147+148+`values` are typed as [`get`](#get) types one cell (blank is `null`, an149+error its text); `text` comes only when asked for; `truncated` is set150+when the range held more cells than `max_cells`, and `rows` says how151+many came back.152+153+Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return154+`{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false155+for a dry run or a change that changed nothing; `create_chart` adds156+`"chart": {"sheet", "number", "title"}` and `create_pivot` `"sheet"`,157+the pivot's.158+159+| Tool | Returns |160+|---|---|161+| `evaluate` | `cell` where it was computed, `value`, `text` as shown, `error` explaining an error value, and `spill`, a `read_range` result, when the formula spilled |162+| `find` | `matches`, each `cell`, `text` and `input`, the first `limit` of them, and `total` |163+| `run_notebook_cell` | `notebook`, `cell`, `name`, `state` (`ran` or `failed`), `error`, `output` as NUON and `cut` when the output was longer than 64 KB |164+165+## version166+167+```json168+{"version": "v0.9.0", "go": "go1.27.1", "os": "darwin", "arch": "arm64"}169+```@@ -12,8 +12,8 @@
1212 <span style="color:var(--vt-palette-8)">├─────────────────────┤│</span> SQLite database (.sqlite) <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> Quit <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Q</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">││</span> JSON list of records (.json) <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">└─────────────────────┘│</span> Nushell table (.nuon) <span style="color:var(--vt-palette-8)">│</span>15-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────┘</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Web page (.html) <span style="color:var(--vt-palette-8)">│</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└──────────────────────────────────┘</span>1717 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">64</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">65</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Code</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">y</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">59</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">60</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -13,8 +13,8 @@
1313 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as SQLite <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>16-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Decimal arithmetic <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Settings</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">225</span> <span style="color:var(--vt-palette-8)">─┘</span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -12,6 +12,8 @@ require (
1212 github.com/charmbracelet/x/ansi v0.11.81313 github.com/charmbracelet/x/term v0.2.21414 github.com/cockroachdb/apd/v3 v3.2.315+ github.com/google/jsonschema-go v0.4.316+ github.com/modelcontextprotocol/go-sdk v1.8.01517 github.com/parquet-go/parquet-go v0.32.01618 github.com/sahilm/fuzzy v0.1.31719 github.com/xuri/excelize/v2 v2.11.0@@ -52,14 +54,19 @@ require (
5254 github.com/richardlehane/mscfb v1.0.7 // indirect5355 github.com/richardlehane/msoleps v1.0.6 // indirect5456 github.com/rivo/uniseg v0.4.7 // indirect57+ github.com/segmentio/asm v1.1.3 // indirect58+ github.com/segmentio/encoding v0.5.4 // indirect5559 github.com/tiendc/go-deepcopy v1.7.2 // indirect5660 github.com/twpayne/go-geom v1.6.1 // indirect5761 github.com/xo/terminfo v1.0.0 // indirect5862 github.com/xuri/efp v0.0.1 // indirect5963 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect64+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect6065 golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect6166 golang.org/x/net v0.59.0 // indirect67+ golang.org/x/oauth2 v0.35.0 // indirect6268 golang.org/x/sync v0.23.0 // indirect69+ golang.org/x/time v0.16.0 // indirect6370 google.golang.org/protobuf v1.36.11 // indirect6471 modernc.org/libc v1.75.7 // indirect6572 modernc.org/mathutil v1.7.1 // indirect@@ -56,8 +56,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
5656 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=5757 github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=5858 github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=59+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=60+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=5961 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=6062 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=63+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=64+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=6165 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=6266 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=6367 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -78,6 +82,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
7882 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=7983 github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=8084 github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=85+github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=86+github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=8187 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=8288 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=8389 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=@@ -104,6 +110,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
104110 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=105111 github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=106112 github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=113+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=114+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=115+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=116+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=107117 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=108118 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=109119 github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=@@ -120,6 +130,8 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
120130 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=121131 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=122132 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=133+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=134+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=123135 go.starlark.net v0.0.0-20260908191801-89a6a09411d5 h1:X8HyonnLxrmAbdeMIEGEJVZ/yg6WykLZyAZmpCLSfMA=124136 go.starlark.net v0.0.0-20260908191801-89a6a09411d5/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=125137 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=@@ -132,6 +144,8 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
132144 golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=133145 golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=134146 golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=147+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=148+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=135149 golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=136150 golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=137151 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=@@ -140,6 +154,8 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
140154 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=141155 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=142156 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=157+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=158+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=143159 golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=144160 golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=145161 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=@@ -46,6 +46,9 @@ type canvas struct {
4646 *image.RGBA4747 cw, ch float644848 pal Palette49+ // vec, when set, takes the shapes as SVG instead of pixels, and50+ // RGBA has no pixels, only its bounds (see SVG).51+ vec *vector4952 }5053 5154 // blend paints c over the pixel at x, y with coverage a.@@ -65,6 +68,10 @@ func (cv *canvas) blend(x, y int, c color.RGBA, a float64) {
6568 6669 // rect fills x0..x1, y0..y1 in pixels, antialiasing fractional edges.6770 func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {71+ if cv.vec != nil {72+ cv.vec.rect(x0, y0, x1, y1, c)73+ return74+ }6875 for y := int(math.Floor(y0)); float64(y) < y1; y++ {6976 cy := min(y1, float64(y+1)) - max(y0, float64(y))7077 for x := int(math.Floor(x0)); float64(x) < x1; x++ {@@ -76,6 +83,10 @@ func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {
7683 7784 // hline draws a one pixel horizontal line at y.7885 func (cv *canvas) hline(y int, c color.RGBA) {86+ if cv.vec != nil {87+ cv.vec.line(float64(cv.Rect.Min.X), float64(y)+0.5, float64(cv.Rect.Max.X), float64(y)+0.5, c)88+ return89+ }7990 for x := cv.Rect.Min.X; x < cv.Rect.Max.X; x++ {8091 cv.blend(x, y, c, 1)8192 }@@ -83,6 +94,10 @@ func (cv *canvas) hline(y int, c color.RGBA) {
8394 8495 // vline draws a one pixel vertical line at x.8596 func (cv *canvas) vline(x int, c color.RGBA) {97+ if cv.vec != nil {98+ cv.vec.line(float64(x)+0.5, float64(cv.Rect.Min.Y), float64(x)+0.5, float64(cv.Rect.Max.Y), c)99+ return100+ }86101 for y := cv.Rect.Min.Y; y < cv.Rect.Max.Y; y++ {87102 cv.blend(x, y, c, 1)88103 }@@ -91,6 +106,10 @@ func (cv *canvas) vline(x int, c color.RGBA) {
91106 // polyline strokes the points with a round-capped line width px wide.92107 // Coverage is kept per pixel, so joints aren't painted twice.93108 func (cv *canvas) polyline(pts [][2]float64, width float64, c color.RGBA) {109+ if cv.vec != nil {110+ cv.vec.polyline(pts, width, c)111+ return112+ }94113 b := cv.Rect95114 cover := make([]float64, b.Dx()*b.Dy())96115 r := width / 2@@ -213,6 +232,10 @@ func (p *barPlan) image(cv *canvas) {
213232 214233 // image draws the pie, supersampled, with a thin gap between slices.215234 func (p *piePlan) image(cv *canvas) {235+ if cv.vec != nil {236+ p.vector(cv)237+ return238+ }216239 b := cv.Rect217240 cx, cy := float64(b.Dx())/2, float64(b.Dy())/2218241 pr := newPieRaster(p, cx, cy, min(cx, cy)-1, 1)@@ -230,6 +230,10 @@ func (p *scatterPlan) image(cv *canvas) {
230230 231231 // disc fills a circle of radius r centered at cx, cy, antialiased.232232 func (cv *canvas) disc(cx, cy, r float64, c color.RGBA) {233+ if cv.vec != nil {234+ cv.vec.disc(cx, cy, r, c)235+ return236+ }233237 for y := int(math.Floor(cy - r - 1)); y <= int(math.Ceil(cy+r+1)); y++ {234238 for x := int(math.Floor(cx - r - 1)); x <= int(math.Ceil(cx+r+1)); x++ {235239 d := math.Hypot(float64(x)+0.5-cx, float64(y)+0.5-cy)@@ -0,0 +1,311 @@
1+package chart2+3+import (4+ "fmt"5+ "html"6+ "image"7+ "image/color"8+ "math"9+ "strings"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// SVG charts are the text chart and the image in one vector drawing,15+// for the HTML export: the axes, labels and legend Draw lays out, as16+// text on a grid of SVGCellW by SVGCellH pixels, and the plot area as17+// the shapes Image paints, as SVG shapes rather than pixels. Colors are18+// classes the page styles, so a chart follows the page's light or dark19+// palette:20+//21+// ct-ax, ct-lb, ct-mu text of the axes, the labels and messages22+// ct-s0 .. ct-s5 text in a series' color23+// cf-0 .. cf-5 shapes filled in a series' color24+// cs-0 .. cs-5 lines stroked in a series' color25+// cs-gl gridlines26+// cs-box axes, in their text class's color27+// cs-gap the gaps between a pie's slices28+const (29+ SVGCellW = 930+ SVGCellH = 2131+)32+33+// vecGrid and vecSeries stand for the colors in the palette a vector34+// canvas paints with, so each shape finds its class again: the red35+// channel is the series and the blue one says it's a gridline.36+var vecGrid = color.RGBA{B: 1, A: 255}37+38+func vecSeries(i int) color.RGBA { return color.RGBA{R: uint8(i), A: 255} }39+40+// SVG draws d as a chart of type t in w by h cells, as an svg element41+// of w*SVGCellW by h*SVGCellH pixels whose title is title.42+func SVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, title string) string {43+ w, h = max(w, 0), max(h, 0)44+ o.Image, o.CellW, o.CellH = true, SVGCellW, SVGCellH45+ g := Draw(t, d, w, h, o)46+ var b strings.Builder47+ fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" class="chart" width="%d" height="%d" viewBox="0 0 %d %d" role="img">`,48+ w*SVGCellW, h*SVGCellH, w*SVGCellW, h*SVGCellH)49+ if title != "" {50+ b.WriteString("<title>" + html.EscapeString(title) + "</title>")51+ }52+ if !g.Plot.Empty() {53+ b.WriteString(plotSVG(t, d, w, h, o, g.Plot))54+ }55+ gridText(&b, g)56+ b.WriteString("</svg>")57+ return b.String()58+}59+60+// plotSVG is the plot area's shapes, placed over its cells.61+func plotSVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, plot image.Rectangle) string {62+ p, _ := planFor(t, d, w, h, o)63+ pal := Palette{Grid: vecGrid}64+ for i := range pal.Series {65+ pal.Series[i] = vecSeries(i)66+ }67+ v := &vector{}68+ cv := &canvas{69+ RGBA: &image.RGBA{Rect: image.Rect(0, 0, plot.Dx()*SVGCellW, plot.Dy()*SVGCellH)},70+ cw: SVGCellW, ch: SVGCellH, pal: pal, vec: v,71+ }72+ p.image(cv)73+ v.flush()74+ return fmt.Sprintf(`<g transform="translate(%d %d)">%s</g>`, plot.Min.X*SVGCellW, plot.Min.Y*SVGCellH, v.b.String())75+}76+77+// gridText writes the text chart's cells, a run of one role at a time.78+func gridText(b *strings.Builder, g *Grid) {79+ for y := range g.H {80+ x := 081+ for x < g.W {82+ c := g.At(x, y)83+ if c.Text == " " || c.Text == "" || c.Fg == None {84+ x++85+ continue86+ }87+ if arms, ok := boxArms[c.Text]; ok {88+ boxLines(b, x, y, arms, textClass(c.Fg))89+ x++90+ continue91+ }92+ start, role := x, c.Fg93+ var run strings.Builder94+ for x < g.W && g.At(x, y).Fg == role && !isBox(g.At(x, y).Text) {95+ run.WriteString(g.At(x, y).Text)96+ x++97+ }98+ fmt.Fprintf(b, `<text x="%d" y="%.1f" class="%s" xml:space="preserve">%s</text>`, start*SVGCellW,99+ float64(y)*SVGCellH+SVGCellH*0.72, textClass(role), html.EscapeString(strings.TrimRight(run.String(), " ")))100+ }101+ }102+}103+104+// boxArms are the box-drawing characters the axes use, as the arms105+// each draws from the middle of its cell: up, down, left, right. They106+// are drawn as lines, which meet whatever font the page has.107+var boxArms = map[string][4]bool{108+ "│": {true, true, false, false}, "─": {false, false, true, true},109+ "└": {true, false, false, true}, "┘": {true, false, true, false},110+ "┌": {false, true, false, true}, "┐": {false, true, true, false},111+ "├": {true, true, false, true}, "┤": {true, true, true, false},112+ "┬": {false, true, true, true}, "┴": {true, false, true, true},113+ "┼": {true, true, true, true},114+}115+116+func isBox(s string) bool { _, ok := boxArms[s]; return ok }117+118+// boxLines draws a box-drawing character's arms in cell x, y.119+func boxLines(b *strings.Builder, x, y int, arms [4]bool, class string) {120+ cx, cy := float64(x)*SVGCellW+SVGCellW/2, float64(y)*SVGCellH+SVGCellH/2121+ ends := [4][2]float64{{cx, float64(y) * SVGCellH}, {cx, float64(y+1) * SVGCellH},122+ {float64(x) * SVGCellW, cy}, {float64(x+1) * SVGCellW, cy}}123+ for i, on := range arms {124+ if on {125+ fmt.Fprintf(b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s cs-box"/>`, num(cx), num(cy), num(ends[i][0]), num(ends[i][1]), class)126+ }127+ }128+}129+130+func textClass(r Role) string {131+ switch {132+ case r == Axis, r == Gridline:133+ return "ct-ax"134+ case r == Label:135+ return "ct-lb"136+ case r >= Series:137+ return fmt.Sprintf("ct-s%d", int(r-Series)%Colors)138+ }139+ return "ct-mu"140+}141+142+// vector collects a canvas's shapes as SVG. Area charts paint a pixel143+// column at a time; those columns are joined into one polygon per color144+// for as long as they run on without a gap.145+type vector struct {146+ b strings.Builder147+ strips map[color.RGBA]*strip148+ order []color.RGBA149+}150+151+// strip is a run of one-pixel columns of one color: their tops and152+// bottoms, left to right.153+type strip struct {154+ x0, x1 float64155+ top, below [][2]float64156+}157+158+func paint(c color.RGBA) (series int, grid bool, opacity string) {159+ if c.A < 255 {160+ opacity = fmt.Sprintf(` fill-opacity="%.2f"`, float64(c.A)/255)161+ }162+ return int(c.R) % Colors, c.B == 1, opacity163+}164+165+func (v *vector) rect(x0, y0, x1, y1 float64, c color.RGBA) {166+ if x1 <= x0 || y1 <= y0 {167+ return168+ }169+ if x1-x0 <= 1.001 {170+ v.column(x0, y0, x1, y1, c)171+ return172+ }173+ v.flush()174+ s, _, op := paint(c)175+ fmt.Fprintf(&v.b, `<rect x="%s" y="%s" width="%s" height="%s" class="cf-%d"%s/>`, num(x0), num(y0), num(x1-x0), num(y1-y0), s, op)176+}177+178+// column adds a pixel column to its color's strip, starting a new strip179+// when it doesn't continue the last one.180+func (v *vector) column(x0, y0, x1, y1 float64, c color.RGBA) {181+ if v.strips == nil {182+ v.strips = map[color.RGBA]*strip{}183+ }184+ st := v.strips[c]185+ if st != nil && math.Abs(st.x1-x0) > 0.001 {186+ v.emitStrip(c, st)187+ st = nil188+ }189+ if st == nil {190+ st = &strip{x0: x0}191+ v.strips[c] = st192+ v.order = append(v.order, c)193+ }194+ st.x1 = x1195+ st.top = append(st.top, [2]float64{x0, y0}, [2]float64{x1, y0})196+ st.below = append(st.below, [2]float64{x0, y1}, [2]float64{x1, y1})197+}198+199+// flush writes the strips still open, in the order they started, so200+// they're under whatever is drawn next.201+func (v *vector) flush() {202+ for _, c := range v.order {203+ if st := v.strips[c]; st != nil {204+ v.emitStrip(c, st)205+ }206+ }207+ v.order = v.order[:0]208+}209+210+func (v *vector) emitStrip(c color.RGBA, st *strip) {211+ delete(v.strips, c)212+ pts := simplify(st.top)213+ below := simplify(st.below)214+ for i := len(below) - 1; i >= 0; i-- {215+ pts = append(pts, below[i])216+ }217+ s, _, op := paint(c)218+ fmt.Fprintf(&v.b, `<polygon points="%s" class="cf-%d"%s/>`, points(pts), s, op)219+}220+221+func (v *vector) line(x0, y0, x1, y1 float64, c color.RGBA) {222+ v.flush()223+ class := "cs-gl"224+ if s, grid, _ := paint(c); !grid {225+ class = fmt.Sprintf("cs-%d", s)226+ }227+ fmt.Fprintf(&v.b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s"/>`, num(x0), num(y0), num(x1), num(y1), class)228+}229+230+func (v *vector) polyline(pts [][2]float64, width float64, c color.RGBA) {231+ v.flush()232+ s, _, _ := paint(c)233+ if len(pts) == 1 {234+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(pts[0][0]), num(pts[0][1]), num(width/2), s)235+ return236+ }237+ fmt.Fprintf(&v.b, `<polyline points="%s" fill="none" stroke-width="%s" stroke-linecap="round" stroke-linejoin="round" class="cs-%d"/>`,238+ points(pts), num(width), s)239+}240+241+func (v *vector) disc(cx, cy, r float64, c color.RGBA) {242+ v.flush()243+ s, _, _ := paint(c)244+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(cx), num(cy), num(r), s)245+}246+247+// vector draws the pie as one path a slice, with the page's background248+// stroked between them as the image leaves a gap.249+func (p *piePlan) vector(cv *canvas) {250+ v := cv.vec251+ b := cv.Rect252+ cx, cy := float64(b.Dx())/2, float64(b.Dy())/2253+ r := min(cx, cy) - 1254+ if len(p.slices) == 1 {255+ fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-0"/>`, num(cx), num(cy), num(r))256+ return257+ }258+ at := func(f float64) string {259+ a := f * 2 * math.Pi260+ return num(cx+r*math.Sin(a)) + " " + num(cy-r*math.Cos(a))261+ }262+ for i, s := range p.slices {263+ if s.to <= s.from {264+ continue265+ }266+ large := 0267+ if s.to-s.from > 0.5 {268+ large = 1269+ }270+ fmt.Fprintf(&v.b, `<path d="M%s %s L%s A%s %s 0 %d 1 %s Z" class="cf-%d cs-gap"/>`,271+ num(cx), num(cy), at(s.from), num(r), num(r), large, at(s.to), i%Colors)272+ }273+}274+275+// simplify drops the points of a path that lie on the line between276+// their neighbors.277+func simplify(pts [][2]float64) [][2]float64 {278+ if len(pts) < 3 {279+ return pts280+ }281+ out := [][2]float64{pts[0]}282+ for i := 1; i < len(pts)-1; i++ {283+ a, b, c := out[len(out)-1], pts[i], pts[i+1]284+ if math.Abs((b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])) > 0.25 {285+ out = append(out, b)286+ }287+ }288+ return append(out, pts[len(pts)-1])289+}290+291+func points(pts [][2]float64) string {292+ var b strings.Builder293+ for i, p := range pts {294+ if i > 0 {295+ b.WriteByte(' ')296+ }297+ b.WriteString(num(p[0]) + "," + num(p[1]))298+ }299+ return b.String()300+}301+302+// num writes a coordinate to a tenth of a pixel, without trailing303+// zeros.304+func num(f float64) string {305+ s := fmt.Sprintf("%.1f", f)306+ s = strings.TrimSuffix(s, ".0")307+ if s == "-0" {308+ return "0"309+ }310+ return s311+}@@ -0,0 +1,71 @@
1+package chart2+3+import (4+ "encoding/xml"5+ "io"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// TestSVGEveryType draws each type as SVG, checking it's well-formed13+// XML with the chart's labels as text and shapes of its own kind.14+func TestSVGEveryType(t *testing.T) {15+ cases := []struct {16+ typ sheet.ChartType17+ data sheet.ChartData18+ opts sheet.ChartOptions19+ shape string20+ }{21+ {sheet.ChartColumn, budget, sheet.ChartOptions{}, "<rect"},22+ {sheet.ChartBar, single, sheet.ChartOptions{}, "<rect"},23+ {sheet.ChartLine, budget, sheet.ChartOptions{}, "<polyline"},24+ {sheet.ChartArea, budget, sheet.ChartOptions{}, "<polygon"},25+ {sheet.ChartArea, budget, sheet.ChartOptions{Stack: sheet.StackNormal}, "<polygon"},26+ {sheet.ChartPie, single, sheet.ChartOptions{}, "<path"},27+ {sheet.ChartScatter, xy, sheet.ChartOptions{Trend: true}, "<circle"},28+ {sheet.ChartColumn, signs, sheet.ChartOptions{Stack: sheet.StackNormal}, "<rect"},29+ }30+ for _, c := range cases {31+ t.Run(c.typ.String(), func(t *testing.T) {32+ svg := SVG(c.typ, c.data, 60, 18, Options{Chart: c.opts}, "Budget & more")33+ d := xml.NewDecoder(strings.NewReader(svg))34+ for {35+ if _, err := d.Token(); err == io.EOF {36+ break37+ } else if err != nil {38+ t.Fatalf("not XML: %v\n%s", err, svg)39+ }40+ }41+ if !strings.Contains(svg, c.shape) {42+ t.Errorf("no %s in\n%s", c.shape, svg)43+ }44+ if !strings.Contains(svg, "<title>Budget & more</title>") {45+ t.Errorf("no title")46+ }47+ if c.typ != sheet.ChartPie && !strings.Contains(svg, `class="ct-lb"`) {48+ t.Errorf("no labels")49+ }50+ })51+ }52+}53+54+// TestSVGAreaIsFewShapes joins an area's pixel columns into polygons.55+func TestSVGAreaIsFewShapes(t *testing.T) {56+ svg := SVG(sheet.ChartArea, single, 80, 20, Options{}, "")57+ if n := strings.Count(svg, "<polygon"); n == 0 || n > 3 {58+ t.Errorf("%d polygons", n)59+ }60+ if strings.Contains(svg, "<rect") {61+ t.Errorf("area columns drawn as rects")62+ }63+}64+65+// TestSVGNothingToPlot shows the text chart's message alone.66+func TestSVGNothingToPlot(t *testing.T) {67+ svg := SVG(sheet.ChartColumn, sheet.ChartData{}, 40, 10, Options{}, "")68+ if !strings.Contains(svg, "No numbers to chart") || strings.Contains(svg, "<g ") {69+ t.Errorf("got %s", svg)70+ }71+}@@ -0,0 +1,93 @@
1+Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.2+3+This Font Software is licensed under the SIL Open Font License, Version 1.1.4+This license is copied below, and is also available with a FAQ at:5+http://scripts.sil.org/OFL6+7+8+-----------------------------------------------------------9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 200710+-----------------------------------------------------------11+12+PREAMBLE13+The goals of the Open Font License (OFL) are to stimulate worldwide14+development of collaborative font projects, to support the font creation15+efforts of academic and linguistic communities, and to provide a free and16+open framework in which fonts may be shared and improved in partnership17+with others.18+19+The OFL allows the licensed fonts to be used, studied, modified and20+redistributed freely as long as they are not sold by themselves. The21+fonts, including any derivative works, can be bundled, embedded,22+redistributed and/or sold with any software provided that any reserved23+names are not used by derivative works. The fonts and derivatives,24+however, cannot be released under any other type of license. The25+requirement for fonts to remain under this license does not apply26+to any document created using the fonts or their derivatives.27+28+DEFINITIONS29+"Font Software" refers to the set of files released by the Copyright30+Holder(s) under this license and clearly marked as such. This may31+include source files, build scripts and documentation.32+33+"Reserved Font Name" refers to any names specified as such after the34+copyright statement(s).35+36+"Original Version" refers to the collection of Font Software components as37+distributed by the Copyright Holder(s).38+39+"Modified Version" refers to any derivative made by adding to, deleting,40+or substituting -- in part or in whole -- any of the components of the41+Original Version, by changing formats or by porting the Font Software to a42+new environment.43+44+"Author" refers to any designer, engineer, programmer, technical45+writer or other person who contributed to the Font Software.46+47+PERMISSION & CONDITIONS48+Permission is hereby granted, free of charge, to any person obtaining49+a copy of the Font Software, to use, study, copy, merge, embed, modify,50+redistribute, and sell modified and unmodified copies of the Font51+Software, subject to the following conditions:52+53+1) Neither the Font Software nor any of its individual components,54+in Original or Modified Versions, may be sold by itself.55+56+2) Original or Modified Versions of the Font Software may be bundled,57+redistributed and/or sold with any software, provided that each copy58+contains the above copyright notice and this license. These can be59+included either as stand-alone text files, human-readable headers or60+in the appropriate machine-readable metadata fields within text or61+binary files as long as those fields can be easily viewed by the user.62+63+3) No Modified Version of the Font Software may use the Reserved Font64+Name(s) unless explicit written permission is granted by the corresponding65+Copyright Holder. This restriction only applies to the primary font name as66+presented to the users.67+68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font69+Software shall not be used to promote, endorse or advertise any70+Modified Version, except to acknowledge the contribution(s) of the71+Copyright Holder(s) and the Author(s) or with their explicit written72+permission.73+74+5) The Font Software, modified or unmodified, in part or in whole,75+must be distributed entirely under this license, and must not be76+distributed under any other license. The requirement for fonts to77+remain under this license does not apply to any document created78+using the Font Software.79+80+TERMINATION81+This license becomes null and void if any of the above conditions are82+not met.83+84+DISCLAIMER85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM93+OTHER DEALINGS IN THE FONT SOFTWARE.Binary file.
Binary file.
Binary file.
@@ -19,6 +19,7 @@ const (
1919 WK12020 JSON2121 NUON22+ HTML2223 )2324 2425 // A fileFormat is everything about a Kind: its names, its extensions,@@ -95,6 +96,10 @@ var kinds = []fileFormat{{
9596 kind: NUON, name: "NUON", noun: "NUON", label: "Nushell table (NUON)", exts: []string{".nuon"},9697 read: importNUON, write: exportNUON, menu: "Nushell table", encode: encodeNUON,9798 about: "Save the sheet as a nushell table that keeps its types, named by its first row",99+}, {100+ kind: HTML, name: "HTML", noun: "HTML", label: "Web page", exts: []string{".html", ".htm"},101+ write: exportHTML, menu: "Web page",102+ about: "Save the sheet as a web page in 012's look, with its charts, to share with anyone",98103 }}99104 100105 // unknownFormat is what an invalid Kind reports.@@ -117,8 +122,18 @@ func Kinds() []Kind {
117122 return out118123 }119124 120-// KindOf recognizes a file's format by its extension.125+// KindOf recognizes a file 012 imports by its extension.121126 func KindOf(name string) (Kind, bool) {127+ k, ok := ExportKindOf(name)128+ if !ok || !k.CanImport() {129+ return 0, false130+ }131+ return k, true132+}133+134+// ExportKindOf recognizes a file's format by its extension, including135+// formats 012 only writes (HTML).136+func ExportKindOf(name string) (Kind, bool) {122137 ext := strings.ToLower(filepath.Ext(name))123138 for _, k := range kinds {124139 for _, e := range k.exts {@@ -130,6 +145,9 @@ func KindOf(name string) (Kind, bool) {
130145 return 0, false131146 }132147 148+// CanImport reports whether 012 reads files of this kind.149+func (k Kind) CanImport() bool { return k.format().read != nil }150+133151 // String is the format's short name, e.g. "XLSX".134152 func (k Kind) String() string { return k.format().name }135153 @@ -10,14 +10,10 @@ func TestFormats(t *testing.T) {
1010 if f.kind != k || k != Kind(i+1) {1111 t.Errorf("row %d is %v, want Kind order", i, f.kind)1212 }13- if f.name == "" || f.noun == "" || f.label == "" || len(f.exts) == 0 || f.read == nil {13+ if f.name == "" || f.noun == "" || f.label == "" || len(f.exts) == 0 || f.read == nil && f.write == nil {1414 t.Errorf("%v: incomplete %+v", k, f)1515 }16- for _, e := range f.exts {17- if got, ok := KindOf("x" + e); !ok || got != k {18- t.Errorf("KindOf(x%s) = %v, %v", e, got, ok)19- }20- }16+ checkExts(t, k)2117 if k.CanExport() != (k.MenuTitle() != "") || k.CanExport() != (k.About() != "") {2218 t.Errorf("%v: export %v, menu %q, about %q", k, k.CanExport(), k.MenuTitle(), k.About())2319 }@@ -35,3 +31,17 @@ func TestFormats(t *testing.T) {
3531 t.Errorf("KindOf(.txt) recognized")3632 }3733 }34+35+// checkExts checks each of k's extensions finds k, for import only36+// when 012 imports it.37+func checkExts(t *testing.T, k Kind) {38+ t.Helper()39+ for _, e := range k.format().exts {40+ if got, ok := ExportKindOf("x" + e); !ok || got != k {41+ t.Errorf("ExportKindOf(x%s) = %v, %v", e, got, ok)42+ }43+ if got, ok := KindOf("x" + e); ok != k.CanImport() || ok && got != k {44+ t.Errorf("KindOf(x%s) = %v, %v", e, got, ok)45+ }46+ }47+}@@ -0,0 +1,125 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "html"7+ "io"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// HTML exports are static pages in 012's look (see htmlstyle.go): the13+// control panel's lines above the grid, the grid with its headers and14+// charts, and a small script that puts the pointer on a cell clicked or15+// moved to with the arrow keys, showing its address in the name box and16+// what it holds in the formula bar, as the screen does. The page needs17+// nothing beyond itself: fonts, styles, SVG charts and script are in18+// the file.19+20+// HTMLPage is what an HTML page shows: a sheet or range, or one chart.21+type HTMLPage struct {22+ Title string // the page's title, and the first line's23+ Snap *Snapshot // the cells, when Chart is nil24+ Chart *SnapChart25+}26+27+// WriteHTML writes p as a page. The note, when there is one, says what28+// the page left out.29+func WriteHTML(w io.Writer, p HTMLPage) (note string, err error) {30+ body, name, where := "", "", ""31+ switch {32+ case p.Chart != nil:33+ body, name, where = HTMLChart(*p.Chart), p.Chart.Data.String(), p.Chart.Type.Title()+" chart of "+p.Chart.Data.String()34+ case p.Snap != nil:35+ body, note = HTMLGrid(p.Snap)36+ where = p.Snap.Name + "!" + p.Snap.Range.String()37+ }38+ _, err = io.WriteString(w, HTMLDocument(p.Title, HTMLPanel(p.Title, name, where)+body, ""))39+ return note, err40+}41+42+// HTMLDocument is a whole page: title, style sheet, body and script,43+// with extra added to the script (the MCP view's messages).44+func HTMLDocument(title, body, extra string) string {45+ return "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">" +46+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +47+ "<meta name=\"generator\" content=\"012\"><title>" + html.EscapeString(title) + "</title>\n<style>\n" +48+ HTMLStyle() + "</style></head>\n<body><div class=\"o12\">" + body +49+ "</div>\n<script>\n" + htmlScript + extra + "</script></body></html>\n"50+}51+52+// HTMLPanel is the control panel's three lines: the title with the53+// mode indicator, the name box (holding name until a cell is pointed54+// at) and formula bar, and the context line saying what the page55+// shows.56+func HTMLPanel(title, name, where string) string {57+ return fmt.Sprintf(`<header class="panel"><div><span class="book">%s</span><span class="mode">READY</span></div>`+58+ `<div><span class="namebox" id="o12-name">%s</span><span class="input" id="o12-input"></span></div>`+59+ `<div class="context" id="o12-context">%s</div></header>`, html.EscapeString(title), html.EscapeString(name), html.EscapeString(where))60+}61+62+// exportHTML writes a sheet or range as a page.63+func exportHTML(_ context.Context, name string, snap *Snapshot, _ ExportOptions) (*ExportResult, error) {64+ var note string65+ err := writeFile(name, func(w io.Writer) error {66+ var err error67+ note, err = WriteHTML(w, HTMLPage{Title: snap.Name, Snap: snap})68+ return err69+ })70+ if err != nil {71+ return nil, err72+ }73+ res := &ExportResult{Rows: snap.Range.To.Row - snap.Range.From.Row + 1}74+ if len(snap.Cells) == 0 {75+ res.Rows = 076+ }77+ if note != "" {78+ res.Notes = append(res.Notes, note)79+ }80+ return res, nil81+}82+83+// ChartSnap is chart i of s with the values it draws, for a page of84+// its own.85+func ChartSnap(s *sheet.Sheet, i int) (SnapChart, bool) {86+ charts := s.Charts()87+ if i < 0 || i >= len(charts) {88+ return SnapChart{}, false89+ }90+ return SnapChart{Chart: charts[i], Values: s.ChartData(charts[i])}, true91+}92+93+// htmlScript moves the pointer: a click or an arrow key puts it on a94+// cell, whose address goes in the name box, what was typed in it in95+// the formula bar, and whose row and column headers light up. It works96+// on grids added to the page later, as the MCP view adds them.97+const htmlScript = `(function(){98+var cur=null;99+function col(s){var n=0;for(var i=0;i<s.length;i++)n=n*26+s.charCodeAt(i)-64;return n}100+function name(n){var s="";while(n>0){n--;s=String.fromCharCode(65+n%26)+s;n=Math.floor(n/26)}return s}101+function split(a){var m=/^([A-Z]+)(\d+)$/.exec(a);return m?[col(m[1]),+m[2]]:null}102+function point(td){103+ if(cur){cur.classList.remove("ptr")}104+ document.querySelectorAll("th.on").forEach(function(t){t.classList.remove("on")});105+ cur=td;if(!td)return;106+ td.classList.add("ptr");107+ var a=td.getAttribute("data-a"),p=split(a),g=td.closest("table");108+ var nb=document.getElementById("o12-name"),inp=document.getElementById("o12-input");109+ if(nb)nb.textContent=a;110+ if(inp)inp.textContent=td.hasAttribute("data-f")?td.getAttribute("data-f"):td.textContent;111+ if(p&&g){var c=g.querySelector('th[data-c="'+name(p[0])+'"]'),r=g.querySelector('th[data-r="'+p[1]+'"]');112+ if(c)c.classList.add("on");if(r)r.classList.add("on")}113+}114+document.addEventListener("click",function(e){var td=e.target.closest&&e.target.closest("td[data-a]");if(td)point(td)});115+document.addEventListener("keydown",function(e){116+ if(!cur)return;var d={ArrowLeft:[-1,0],ArrowRight:[1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[e.key];if(!d)return;117+ var p=split(cur.getAttribute("data-a")),g=cur.closest("table");if(!p||!g)return;118+ for(var k=1;k<64;k++){var n=g.querySelector('td[data-a="'+name(p[0]+d[0]*k)+(p[1]+d[1]*k)+'"]');119+ if(n){point(n);n.scrollIntoView({block:"nearest",inline:"nearest"});e.preventDefault();return}120+ if(p[0]+d[0]*k<1||p[1]+d[1]*k<1)return}121+});122+window.o12point=point;123+var first=document.querySelector("td[data-a]");if(first)point(first);124+})();125+`@@ -0,0 +1,139 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "regexp"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// htmlBook is a sheet with a header, numbers, a formula, a style, a16+// merge, a note and a chart.17+func htmlBook(t *testing.T) *sheet.Sheet {18+ t.Helper()19+ s := sheet.New()20+ for a, in := range map[string]string{21+ "A1": "Month", "B1": "Sales", "A2": "Jan", "B2": "$1,200", "A3": "Feb", "B3": "900",22+ "A4": "Total", "B4": "=SUM(B2:B3)", "A6": "<b>not markup</b>",23+ } {24+ addr, _ := sheet.ParseAddr(a)25+ if err := s.Set(addr, in); err != nil {26+ t.Fatal(err)27+ }28+ }29+ s.SetStyle(sheet.Rect{To: sheet.Addr{Col: 1}}, func(st *sheet.Style) { st.Bold = true })30+ if err := s.SetNote(sheet.Addr{Row: 3}, "adds up"); err != nil {31+ t.Fatal(err)32+ }33+ if err := s.Merge(sheet.Rect{From: sheet.Addr{Row: 5}, To: sheet.Addr{Col: 1, Row: 5}}, sheet.MergeAll); err != nil {34+ t.Fatal(err)35+ }36+ s.AddChart(sheet.Chart{Type: sheet.ChartColumn, Data: sheet.Rect{To: sheet.Addr{Col: 1, Row: 2}}, Header: true, Labels: true,37+ Title: "Sales by month", At: sheet.Addr{Col: 3, Row: 1}, W: 40, H: 14})38+ return s39+}40+41+// TestHTMLPage exports a sheet as a page that holds everything it42+// needs: fonts, styles, the cells as shown, the chart as SVG.43+func TestHTMLPage(t *testing.T) {44+ s := htmlBook(t)45+ path := filepath.Join(t.TempDir(), "sales.html")46+ k, ok := ExportKindOf(path)47+ if !ok || k != HTML || k.CanImport() {48+ t.Fatalf("ExportKindOf = %v, %v", k, ok)49+ }50+ if _, ok := KindOf(path); ok {51+ t.Errorf("an HTML file is offered for import")52+ }53+ res, err := Export(context.Background(), path, HTML, Snap(s, sheet.Rect{}, "Sales"), ExportOptions{})54+ if err != nil {55+ t.Fatal(err)56+ }57+ if res.Rows != 6 || len(res.Notes) != 0 {58+ t.Errorf("rows %d, notes %q", res.Rows, res.Notes)59+ }60+ data, err := os.ReadFile(path)61+ if err != nil {62+ t.Fatal(err)63+ }64+ page := string(data)65+ for _, want := range []string{66+ "<title>Sales</title>", "@font-face{font-family:'IBM Plex Mono'", "data:font/woff2;base64,",67+ `<th data-c="A">A</th>`, `data-a="B1" class="b">Sales<`, `data-a="A2" class="clip">Jan<`, `data-a="B2" class="r">$1,200<`,68+ `data-f="=SUM(B2:B3)">$2,100<`, `class="clip note" title="adds up"`, `colspan="2" rowspan="1"`, "<b>not markup</b>",69+ `<figure class="chart"`, "<svg", "<figcaption>Sales by month</figcaption>", `class="range">A1:B3<`,70+ "prefers-color-scheme:light", "READY",71+ } {72+ if !strings.Contains(page, want) {73+ t.Errorf("page lacks %q", want)74+ }75+ }76+ // Nothing is fetched from elsewhere.77+ if m := regexp.MustCompile(`(src|href)="?https?:`).FindString(page); m != "" {78+ t.Errorf("page loads %s", m)79+ }80+}81+82+// TestHTMLRange exports a range, with the charts anchored in it.83+func TestHTMLRange(t *testing.T) {84+ s := htmlBook(t)85+ var b bytes.Buffer86+ snap := Snap(s, sheet.Rect{From: sheet.Addr{Row: 1}, To: sheet.Addr{Col: 1, Row: 2}}, "Sales")87+ if _, err := WriteHTML(&b, HTMLPage{Title: "Sales", Snap: snap}); err != nil {88+ t.Fatal(err)89+ }90+ page := b.String()91+ if strings.Contains(page, `data-a="A1"`) || !strings.Contains(page, `data-a="B3"`) || strings.Contains(page, "<svg") {92+ t.Errorf("range page wrong:\n%s", page[strings.Index(page, "<body>"):])93+ }94+ if !strings.Contains(page, `id="o12-context">Sales!A2:B3<`) {95+ t.Errorf("context line doesn't name the range")96+ }97+}98+99+// TestHTMLChartPage draws one chart on its own page.100+func TestHTMLChartPage(t *testing.T) {101+ s := htmlBook(t)102+ c, ok := ChartSnap(s, 0)103+ if !ok {104+ t.Fatal("no chart")105+ }106+ if _, ok := ChartSnap(s, 1); ok {107+ t.Error("chart 1 found")108+ }109+ var b bytes.Buffer110+ if _, err := WriteHTML(&b, HTMLPage{Title: "Sales by month", Chart: &c}); err != nil {111+ t.Fatal(err)112+ }113+ page := b.String()114+ if !strings.Contains(page, `<figure class="chart solo"`) || !strings.Contains(page, "<rect") || strings.Contains(page, "<table") {115+ t.Errorf("chart page wrong")116+ }117+}118+119+// TestHTMLLooks draws a rule's fill and text color.120+func TestHTMLLooks(t *testing.T) {121+ s := sheet.New()122+ for i, in := range []string{"5", "50"} {123+ if err := s.Set(sheet.Addr{Row: i}, in); err != nil {124+ t.Fatal(err)125+ }126+ }127+ err := s.AddCondFormat(sheet.CondFormat{Ranges: []sheet.Rect{{To: sheet.Addr{Row: 1}}}, Op: sheet.RuleGreater, Args: [2]string{"10"},128+ Style: sheet.RuleStyle{Fill: sheet.ColorGreen, Bold: true}})129+ if err != nil {130+ t.Fatal(err)131+ }132+ grid, _ := HTMLGrid(Snap(s, sheet.Rect{}, "S"))133+ if !strings.Contains(grid, `data-a="A2" class="r b" style="background:var(--r3);color:var(--i3)"`) {134+ t.Errorf("rule not drawn:\n%s", grid)135+ }136+ if strings.Contains(grid, `data-a="A1" class="r b"`) {137+ t.Errorf("rule drawn where it doesn't apply")138+ }139+}@@ -0,0 +1,221 @@
1+package fileio2+3+import (4+ "fmt"5+ "html"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// htmlCell is what a td says about its cell: classes, inline styles,12+// attributes and the text shown.13+type htmlCell struct {14+ class []string15+ style []string16+ attrs string17+ text string18+ icon string // an icon set's glyph, before the text19+}20+21+// writeCell writes the td of the cell at a: spanning a merge, with the22+// text as the grid shows it at the cell's width.23+func (l *htmlLayout) writeCell(b *strings.Builder, a sheet.Addr) {24+ snap := l.snap25+ var h htmlCell26+ width := htmlColWidth(snap, a.Col)27+ if m, ok := l.merged[a]; ok {28+ width = 029+ for col := m.From.Col; col <= m.To.Col; col++ {30+ width += htmlColWidth(snap, col)31+ }32+ h.attrs += fmt.Sprintf(` colspan="%d" rowspan="%d"`, m.To.Col-m.From.Col+1, l.visibleRows(m))33+ }34+ c, filled := snap.Cells[a]35+ if filled {36+ h.content(snap, c, width)37+ h.textStyle(c.Style)38+ h.borders(c.Style.Borders)39+ if c.Style.Wrap == sheet.WrapOverflow && h.text != "" && l.nextFilled(a) {40+ h.class = append(h.class, "clip")41+ }42+ }43+ if look, ok := snap.Looks[a]; ok {44+ h.look(look)45+ }46+ if role := l.tableRole[a]; role != "" {47+ h.class = append(h.class, role)48+ }49+ if note, ok := snap.Notes[a]; ok {50+ h.class = append(h.class, "note")51+ h.attrs += ` title="` + html.EscapeString(note) + `"`52+ }53+ fmt.Fprintf(b, `<td data-a="%s"`, a)54+ if len(h.class) > 0 {55+ b.WriteString(` class="` + strings.Join(h.class, " ") + `"`)56+ }57+ if len(h.style) > 0 {58+ b.WriteString(` style="` + html.EscapeString(strings.Join(h.style, ";")) + `"`)59+ }60+ b.WriteString(h.attrs + ">")61+ if h.icon != "" {62+ b.WriteString(h.icon)63+ }64+ b.WriteString(html.EscapeString(h.text) + "</td>")65+}66+67+// visibleRows is how many drawn rows a merge spans.68+func (l *htmlLayout) visibleRows(m sheet.Rect) int {69+ n := 070+ for row := m.From.Row; row <= m.To.Row; row++ {71+ if !l.snap.HiddenRows[row] {72+ n++73+ }74+ }75+ return max(n, 1)76+}77+78+// nextFilled reports whether the cell right of a holds something, which79+// a's text stops at rather than running on, as in Sheets.80+func (l *htmlLayout) nextFilled(a sheet.Addr) bool {81+ next := sheet.Addr{Col: a.Col + 1, Row: a.Row}82+ c, ok := l.snap.Cells[next]83+ return ok && (c.Input != "" || c.Value.Kind != sheet.Empty) || l.covered[next]84+}85+86+// content is the cell's text as the grid shows it in width columns,87+// where it goes, and what was typed when that isn't the text.88+func (h *htmlCell) content(snap *Snapshot, c SnapCell, width int) {89+ text, align := sheet.DisplayIn(c.Value, c.Format, width, snap.Locale)90+ h.text = text91+ switch c.Style.Align {92+ case sheet.AlignLeft:93+ align = sheet.AlignLeft94+ case sheet.AlignCenter:95+ align = sheet.AlignCenter96+ case sheet.AlignRight:97+ align = sheet.AlignRight98+ }99+ switch align {100+ case sheet.AlignRight:101+ h.class = append(h.class, "r")102+ case sheet.AlignCenter:103+ h.class = append(h.class, "c")104+ }105+ if c.Value.Kind == sheet.Error {106+ h.class = append(h.class, "err")107+ }108+ if c.Input != "" && c.Input != text {109+ h.attrs += ` data-f="` + html.EscapeString(c.Input) + `"`110+ }111+ switch c.Style.Wrap {112+ case sheet.WrapOn:113+ h.class = append(h.class, "wrap")114+ case sheet.WrapClip:115+ h.class = append(h.class, "clip")116+ }117+ switch c.Style.VAlign {118+ case sheet.VAlignTop:119+ h.class = append(h.class, "t")120+ case sheet.VAlignMiddle:121+ h.class = append(h.class, "m")122+ }123+}124+125+// textStyle adds bold, italic, underline and strikethrough.126+func (h *htmlCell) textStyle(st sheet.Style) {127+ for _, f := range []struct {128+ on bool129+ class string130+ }{{st.Bold, "b"}, {st.Italic, "i"}, {st.Underline, "u"}, {st.Strikethrough, "s"}} {131+ if f.on {132+ h.class = append(h.class, f.class)133+ }134+ }135+}136+137+// borders draws each edge's line in its color, or the border role's138+// when it has none.139+func (h *htmlCell) borders(b sheet.Borders) {140+ if b.IsZero() {141+ return142+ }143+ for _, e := range []struct {144+ edge sheet.Edge145+ side string146+ }{{sheet.EdgeTop, "top"}, {sheet.EdgeBottom, "bottom"}, {sheet.EdgeLeft, "left"}, {sheet.EdgeRight, "right"}} {147+ st := b.Stroke(e.edge)148+ line := ""149+ switch st.Line {150+ case sheet.LineThin:151+ line = "1px solid"152+ case sheet.LineThick:153+ line = "2px solid"154+ case sheet.LineDouble:155+ line = "3px double"156+ default:157+ continue158+ }159+ color := "var(--border)"160+ if st.Color != sheet.ColorNone {161+ color = ruleVar(st.Color)162+ }163+ h.style = append(h.style, "border-"+e.side+":"+line+" "+color)164+ }165+}166+167+func ruleVar(c sheet.Color) string { return fmt.Sprintf("var(--r%d)", int(c)) }168+func inkVar(c sheet.Color) string { return fmt.Sprintf("var(--i%d)", int(c)) }169+170+// look draws what the sheet's rules show: a conditional format's171+// colors and text style, a color scale, a data bar, an icon, a172+// checkbox, a dropdown's marker and an invalid entry's underline.173+func (h *htmlCell) look(l sheet.Look) {174+ if l.Styled {175+ h.ruleStyle(l.Style)176+ }177+ if l.Scaled {178+ pct := int(l.Pos*100 + 0.5)179+ h.style = append(h.style, fmt.Sprintf("background:color-mix(in srgb,%s %d%%,%s)", ruleVar(l.To), pct, ruleVar(l.From)))180+ ink := l.From181+ if l.Pos >= 0.5 {182+ ink = l.To183+ }184+ h.style = append(h.style, "color:"+inkVar(ink))185+ }186+ if l.Bar {187+ pct := int(l.BarLen*100 + 0.5)188+ h.style = append(h.style, fmt.Sprintf("background:linear-gradient(to right,color-mix(in srgb,%s 55%%,transparent) %d%%,transparent %d%%)",189+ ruleVar(l.BarColor), pct, pct))190+ }191+ if l.ValueHidden {192+ h.text = ""193+ }194+ if l.Icon != "" {195+ h.icon = fmt.Sprintf(`<span class="icon" style="color:%s">%s</span>`, ruleVar(l.IconColor), html.EscapeString(l.Icon))196+ }197+ switch {198+ case l.Checkbox && l.Checked:199+ h.text, h.class = "[✓]", append(h.class, "c")200+ case l.Checkbox:201+ h.text, h.class = "[ ]", append(h.class, "c")202+ case l.Dropdown:203+ h.text += " ▾"204+ }205+ if l.Invalid {206+ h.class = append(h.class, "bad")207+ }208+}209+210+// ruleStyle is a single-color rule's fill, text color and text style.211+func (h *htmlCell) ruleStyle(st sheet.RuleStyle) {212+ switch {213+ case st.Fill != sheet.ColorNone && (st.Text == sheet.ColorNone || st.Text == st.Fill):214+ h.style = append(h.style, "background:"+ruleVar(st.Fill), "color:"+inkVar(st.Fill))215+ case st.Fill != sheet.ColorNone:216+ h.style = append(h.style, "background:"+ruleVar(st.Fill), "color:"+ruleVar(st.Text))217+ case st.Text != sheet.ColorNone:218+ h.style = append(h.style, "color:"+ruleVar(st.Text))219+ }220+ h.textStyle(sheet.Style{Bold: st.Bold, Italic: st.Italic, Underline: st.Underline, Strikethrough: st.Strikethrough})221+}@@ -0,0 +1,239 @@
1+package fileio2+3+import (4+ "fmt"5+ "html"6+ "sort"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/chart"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// maxHTMLCells bounds the cells a page draws, blank ones included: a15+// browser shows a table of about this many at once without trouble.16+// Past it the page draws the first rows and says so.17+const maxHTMLCells = 200_00018+19+// htmlLayout is where the grid's rows and columns go: the columns and20+// rows drawn, in order, and the pixel offset of each from the grid's21+// top-left corner, headers included.22+type htmlLayout struct {23+ snap *Snapshot24+ cols, rows []int25+ x, y map[int]int // left edge of a column, top edge of a row26+ headW int // the row numbers' width27+ cut int // rows left out past maxHTMLCells28+ merged map[sheet.Addr]sheet.Rect29+ covered map[sheet.Addr]bool30+ tableRole map[sheet.Addr]string31+}32+33+// layout places the snapshot's range, grown to hold its charts.34+func layout(snap *Snapshot) *htmlLayout {35+ r := snap.Range36+ colsTo, rowsTo := r.To.Col, r.To.Row37+ for _, c := range snap.Charts {38+ colsTo = max(colsTo, chartEndCol(snap, c))39+ rowsTo = max(rowsTo, chartEndRow(snap, c))40+ }41+ l := &htmlLayout{snap: snap, x: map[int]int{}, y: map[int]int{}}42+ for col := r.From.Col; col <= min(colsTo, sheet.MaxCols-1); col++ {43+ l.cols = append(l.cols, col)44+ }45+ maxRows := max(maxHTMLCells/max(len(l.cols), 1), 100)46+ for row := r.From.Row; row <= min(rowsTo, sheet.MaxRows-1); row++ {47+ if snap.HiddenRows[row] {48+ continue49+ }50+ if len(l.rows) == maxRows {51+ l.cut = rowsTo - row + 152+ break53+ }54+ l.rows = append(l.rows, row)55+ }56+ last := r.From.Row + 157+ if len(l.rows) > 0 {58+ last = l.rows[len(l.rows)-1] + 159+ }60+ l.headW = (max(len(strconv.Itoa(last)), 3) + 2) * htmlCellW61+ x := l.headW62+ for _, col := range l.cols {63+ l.x[col] = x64+ x += htmlColWidth(snap, col) * htmlCellW65+ }66+ y := htmlCellH67+ for _, row := range l.rows {68+ l.y[row] = y69+ y += htmlRowHeight(snap, row) * htmlCellH70+ }71+ l.mergesAndTables()72+ return l73+}74+75+func htmlColWidth(snap *Snapshot, col int) int {76+ if w, ok := snap.Widths[col]; ok {77+ return w78+ }79+ return sheet.DefaultWidth80+}81+82+func htmlRowHeight(snap *Snapshot, row int) int {83+ if h, ok := snap.Heights[row]; ok && h > 0 {84+ return h85+ }86+ return 187+}88+89+// chartEndCol is the last column a chart covers.90+func chartEndCol(snap *Snapshot, c SnapChart) int {91+ col, w := c.At.Col, c.W92+ for w > htmlColWidth(snap, col) && col < sheet.MaxCols-1 {93+ w -= htmlColWidth(snap, col)94+ col++95+ }96+ return col97+}98+99+// chartEndRow is the last row a chart covers.100+func chartEndRow(snap *Snapshot, c SnapChart) int {101+ row, h := c.At.Row, c.H102+ for h > htmlRowHeight(snap, row) && row < sheet.MaxRows-1 {103+ h -= htmlRowHeight(snap, row)104+ row++105+ }106+ return row107+}108+109+// mergesAndTables notes the merged cells and the cells tables style.110+func (l *htmlLayout) mergesAndTables() {111+ l.merged, l.covered, l.tableRole = map[sheet.Addr]sheet.Rect{}, map[sheet.Addr]bool{}, map[sheet.Addr]string{}112+ for _, m := range l.snap.Merges {113+ l.merged[m.From] = m114+ for row := m.From.Row; row <= m.To.Row; row++ {115+ for col := m.From.Col; col <= m.To.Col; col++ {116+ if a := (sheet.Addr{Col: col, Row: row}); a != m.From {117+ l.covered[a] = true118+ }119+ }120+ }121+ }122+ for _, t := range l.snap.Tables {123+ for col := t.Range.From.Col; col <= t.Range.To.Col; col++ {124+ if t.Header {125+ l.tableRole[sheet.Addr{Col: col, Row: t.Range.From.Row}] = "th"126+ }127+ for row := t.Range.From.Row + 2; t.Banded && row <= t.Range.To.Row; row += 2 {128+ l.tableRole[sheet.Addr{Col: col, Row: row}] = "band"129+ }130+ }131+ }132+}133+134+// HTMLGrid draws the snapshot as 012's grid: column letters and row135+// numbers, the cells as they show with their styles, borders and the136+// looks of rules, and the charts over them. It returns the fragment and137+// a note when rows were left out.138+func HTMLGrid(snap *Snapshot) (string, string) {139+ l := layout(snap)140+ var b strings.Builder141+ b.WriteString(`<div class="sheet"><table class="grid"><colgroup>`)142+ fmt.Fprintf(&b, `<col style="width:%dpx">`, l.headW)143+ for _, col := range l.cols {144+ fmt.Fprintf(&b, `<col style="width:%dpx">`, htmlColWidth(snap, col)*htmlCellW)145+ }146+ b.WriteString(`</colgroup><thead><tr><th class="corner"></th>`)147+ for _, col := range l.cols {148+ fmt.Fprintf(&b, `<th data-c="%s">%s</th>`, sheet.ColName(col), sheet.ColName(col))149+ }150+ b.WriteString("</tr></thead><tbody>")151+ for _, row := range l.rows {152+ l.writeRow(&b, row)153+ }154+ b.WriteString("</tbody></table>")155+ for _, c := range snap.Charts {156+ l.writeChart(&b, c)157+ }158+ b.WriteString("</div>")159+ note := ""160+ if l.cut > 0 {161+ note = fmt.Sprintf("HTML pages show the first %d rows: %d more are left out", len(l.rows), l.cut)162+ }163+ return b.String(), note164+}165+166+func (l *htmlLayout) writeRow(b *strings.Builder, row int) {167+ style := ""168+ if h := htmlRowHeight(l.snap, row); h > 1 {169+ style = fmt.Sprintf(` style="height:%dpx"`, h*htmlCellH)170+ }171+ fmt.Fprintf(b, `<tr%s><th data-r="%d">%d</th>`, style, row+1, row+1)172+ for _, col := range l.cols {173+ a := sheet.Addr{Col: col, Row: row}174+ if l.covered[a] {175+ continue176+ }177+ l.writeCell(b, a)178+ }179+ b.WriteString("</tr>")180+}181+182+// writeChart draws a chart in its frame over the cells under it, as183+// the screen does: the title in the top border, the range in the184+// bottom one.185+func (l *htmlLayout) writeChart(b *strings.Builder, c SnapChart) {186+ x, y := l.at(c.At)187+ fmt.Fprintf(b, `<figure class="chart" style="left:%dpx;top:%dpx;width:%dpx;height:%dpx">`,188+ x, y, c.W*htmlCellW, c.H*htmlCellH)189+ writeChartBody(b, c)190+ b.WriteString("</figure>")191+}192+193+// writeChartBody is a chart's frame labels and drawing, inside its194+// figure.195+func writeChartBody(b *strings.Builder, c SnapChart) {196+ fmt.Fprintf(b, `<figcaption>%s</figcaption>`, html.EscapeString(chartTitle(c.Chart)))197+ b.WriteString(chart.SVG(c.Type, c.Values, c.W-4, c.H-2, chart.Options{Chart: c.ChartOptions}, chartTitle(c.Chart)))198+ fmt.Fprintf(b, `<span class="range">%s</span>`, c.Data.String())199+}200+201+// chartTitle is a chart's title, or its type's name when it has none.202+func chartTitle(c sheet.Chart) string {203+ if c.Title != "" {204+ return c.Title205+ }206+ return c.Type.Title() + " chart"207+}208+209+// at is the pixel offset of a cell's top-left corner; a cell in a210+// hidden row or past the drawn ones sits where the next drawn one does.211+func (l *htmlLayout) at(a sheet.Addr) (int, int) {212+ x, ok := l.x[a.Col]213+ if !ok {214+ x = l.headW215+ }216+ y, ok := l.y[a.Row]217+ if !ok {218+ i := sort.SearchInts(l.rows, a.Row)219+ switch {220+ case i < len(l.rows):221+ y = l.y[l.rows[i]]222+ case len(l.rows) > 0:223+ last := l.rows[len(l.rows)-1]224+ y = l.y[last] + htmlRowHeight(l.snap, last)*htmlCellH225+ default:226+ y = htmlCellH227+ }228+ }229+ return x, y230+}231+232+// HTMLChart draws one chart alone, in its frame.233+func HTMLChart(c SnapChart) string {234+ var b strings.Builder235+ fmt.Fprintf(&b, `<figure class="chart solo" style="width:%dpx;height:%dpx">`, c.W*htmlCellW, c.H*htmlCellH)236+ writeChartBody(&b, c)237+ b.WriteString("</figure>")238+ return b.String()239+}@@ -0,0 +1,169 @@
1+package fileio2+3+import (4+ "embed"5+ "encoding/base64"6+ "fmt"7+ "math"8+ "strings"9+ "sync"10+11+ "github.com/FelineStateMachine/012/internal/chart"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// HTML pages are drawn in 012's look: the 16 ANSI colors of the16+// reference palettes the golden screens use (e2e/palette_test.go),17+// mapped to the roles the terminal theme gives them18+// (internal/ui/theme), dark or light as the reader's system prefers,19+// in IBM Plex Mono, the docs site's font, embedded so the page needs20+// nothing else. One cell is htmlCellW by htmlCellH pixels, the size of21+// a cell of an SVG chart, so charts sit on the grid as on the screen.22+23+const (24+ htmlCellW = chart.SVGCellW25+ htmlCellH = chart.SVGCellH26+)27+28+//go:embed fonts/*.woff229+var fonts embed.FS30+31+// htmlPalette is a reference palette and the roles drawn in it.32+type htmlPalette struct {33+ ansi [16]uint3234+ bg, fg uint3235+ grid uint32 // the faint lines between cells36+ // ANSI slots of the roles.37+ headerBg, headerFg, bar, muted, border, note, link int38+ series [chart.Colors]int39+}40+41+var htmlPalettes = [2]htmlPalette{{42+ ansi: [16]uint32{0x1d1f21, 0xcc6666, 0xb5bd68, 0xf0c674, 0x81a2be, 0xb294bb, 0x8abeb7, 0xc5c8c6,43+ 0x666666, 0xd54e53, 0xb9ca4a, 0xe7c547, 0x7aa6da, 0xc397d8, 0x70c0b1, 0xeaeaea},44+ bg: 0x1d1f21, fg: 0xc5c8c6, grid: 0x2c2e30,45+ headerBg: 8, headerFg: 15, bar: 6, muted: 8, border: 7, note: 3, link: 12,46+ series: [chart.Colors]int{6, 5, 3, 2, 12, 9},47+}, {48+ ansi: [16]uint32{0x1d1f21, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xd6d6d6,49+ 0x8e908c, 0xc82829, 0x718c00, 0xb58900, 0x4271ae, 0x8959a8, 0x3e999f, 0xffffff},50+ bg: 0xfafafa, fg: 0x1d1f21, grid: 0xe8e8e8,51+ headerBg: 7, headerFg: 0, bar: 4, muted: 0, border: 0, note: 5, link: 4,52+ series: [chart.Colors]int{4, 5, 2, 1, 6, 8},53+}}54+55+// ruleSlots are the ANSI slots of sheet.Color, as the theme maps them.56+var ruleSlots = [sheet.NumColors]int{0, 1, 3, 2, 6, 4, 5}57+58+// vars writes the palette's CSS variables.59+func (p htmlPalette) vars() string {60+ var b strings.Builder61+ hex := func(v uint32) string { return fmt.Sprintf("#%06x", v) }62+ slot := func(i int) string { return hex(p.ansi[i]) }63+ fmt.Fprintf(&b, "--bg:%s;--fg:%s;--grid:%s;--hbg:%s;--hfg:%s;--bar:%s;--muted:%s;--border:%s;--note:%s;--link:%s;--axis:%s;--acc:%s;--accfg:%s;",64+ hex(p.bg), hex(p.fg), hex(p.grid), slot(p.headerBg), slot(p.headerFg), slot(p.bar), slot(p.muted),65+ slot(p.border), slot(p.note), slot(p.link), slot(8), slot(6), slot(0))66+ for i, s := range p.series {67+ fmt.Fprintf(&b, "--s%d:%s;", i, slot(s))68+ }69+ for c := 1; c < sheet.NumColors; c++ {70+ fill := p.ansi[ruleSlots[c]]71+ fmt.Fprintf(&b, "--r%d:%s;--i%d:%s;", c, hex(fill), c, hex(p.ink(fill)))72+ }73+ return b.String()74+}75+76+// ink is black or bright white, whichever reads better on bg.77+func (p htmlPalette) ink(bg uint32) uint32 {78+ if contrast(p.ansi[0], bg) >= contrast(p.ansi[15], bg) {79+ return p.ansi[0]80+ }81+ return p.ansi[15]82+}83+84+// contrast is the WCAG contrast ratio of two colors.85+func contrast(a, b uint32) float64 {86+ la, lb := luminance(a), luminance(b)87+ return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)88+}89+90+func luminance(c uint32) float64 {91+ ch := func(v uint32) float64 {92+ s := float64(v&0xff) / 25593+ if s <= 0.03928 {94+ return s / 12.9295+ }96+ return math.Pow((s+0.055)/1.055, 2.4)97+ }98+ return 0.2126*ch(c>>16) + 0.7152*ch(c>>8) + 0.0722*ch(c)99+}100+101+// htmlCSS is the style sheet every page and view shares, fonts102+// included.103+var htmlCSS = sync.OnceValue(func() string {104+ var b strings.Builder105+ for _, f := range []struct{ file, weight, style string }{106+ {"ibm-plex-mono-latin-400-normal.woff2", "400", "normal"},107+ {"ibm-plex-mono-latin-600-normal.woff2", "600", "normal"},108+ {"ibm-plex-mono-latin-400-italic.woff2", "400", "italic"},109+ } {110+ data, _ := fonts.ReadFile("fonts/" + f.file)111+ fmt.Fprintf(&b, "@font-face{font-family:'IBM Plex Mono';font-weight:%s;font-style:%s;src:url(data:font/woff2;base64,%s) format('woff2')}\n",112+ f.weight, f.style, base64.StdEncoding.EncodeToString(data))113+ }114+ dark, light := htmlPalettes[0].vars(), htmlPalettes[1].vars()115+ fmt.Fprintf(&b, ":root{%s}\n@media (prefers-color-scheme:light){:root:not(.dark){%s}}\n:root.light{%s}\n", dark, light, light)116+ b.WriteString(htmlRules)117+ return b.String()118+})119+120+// HTMLStyle is the style sheet of 012's HTML pages, for a page built121+// around HTMLGrid or HTMLChart; the root element takes the class dark122+// or light to choose a palette over the reader's preference.123+func HTMLStyle() string { return htmlCSS() }124+125+// htmlRules are the page's rules, after its fonts and palettes.126+const htmlRules = `*{box-sizing:border-box}127+html,body{margin:0;background:var(--bg);color:var(--fg)}128+body{font:15px/21px 'IBM Plex Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-variant-ligatures:none}129+.o12{min-width:100%;width:max-content}130+.panel{position:sticky;left:0;top:0;z-index:3;background:var(--bg);white-space:pre;width:100vw;max-width:100%}131+.panel>div{height:21px;display:flex;gap:9px;overflow:hidden}132+.panel .book{flex:1;overflow:hidden;text-overflow:ellipsis}133+.mode{background:var(--acc);color:var(--accfg);font-weight:600;padding:0 9px}134+.namebox{background:var(--hbg);color:var(--hfg);min-width:90px;padding:0 9px}135+.input{flex:1;overflow:hidden;text-overflow:ellipsis}136+.context{color:var(--muted)}137+.sheet{position:relative}138+table.grid{border-collapse:collapse;table-layout:fixed;width:max-content}139+.grid th{background:var(--hbg);color:var(--hfg);font-weight:400;height:21px;padding:0;text-align:center;position:sticky;z-index:1}140+.grid thead th{top:63px}141+.grid tbody th{left:0;text-align:right;padding-right:9px;vertical-align:bottom}142+.grid thead th.corner{left:0;z-index:2}143+.grid th.on{background:var(--acc);color:var(--accfg);font-weight:600}144+.grid td{height:21px;padding:0 4px;white-space:pre;overflow:visible;box-shadow:inset -1px -1px 0 var(--grid);vertical-align:bottom;position:relative}145+.grid td.clip{overflow:hidden}146+.grid td.wrap{white-space:pre-wrap;overflow-wrap:anywhere}147+.grid td.r{text-align:right}.grid td.c{text-align:center}.grid td.l{text-align:left}148+.grid td.t{vertical-align:top}.grid td.m{vertical-align:middle}149+.grid td.b{font-weight:600}.grid td.i{font-style:italic}150+.grid td.u{text-decoration:underline}.grid td.s{text-decoration:line-through}.grid td.u.s{text-decoration:underline line-through}151+.grid td.err{text-decoration:underline wavy var(--r1)}152+.grid td.bad{text-decoration:underline dotted var(--r2)}153+.grid td.th{font-weight:600;text-decoration:underline;color:var(--bar)}154+.grid td.band{background:var(--hbg);color:var(--hfg)}155+.grid td.note::after{content:'\259D';position:absolute;right:0;top:-3px;color:var(--note);font-size:12px}156+.grid td.ptr{outline:2px solid var(--acc);outline-offset:-2px}157+.grid a{color:var(--link)}158+.grid .icon{margin-right:9px}159+figure.chart{position:absolute;margin:0;border:1px solid var(--axis);background:var(--bg);padding:20px 17px;z-index:1}160+figure.chart figcaption{position:absolute;top:-11px;left:9px;background:var(--bg);padding:0 9px;font-weight:600;white-space:pre}161+figure.chart .range{position:absolute;bottom:-11px;right:9px;background:var(--bg);padding:0 9px;color:var(--muted);white-space:pre}162+figure.solo{position:relative;display:inline-block;margin:21px 18px}163+svg.chart{display:block;overflow:visible;font:15px 'IBM Plex Mono',ui-monospace,monospace}164+.ct-ax{fill:var(--axis);color:var(--axis)}.ct-lb{fill:var(--muted);color:var(--muted)}.ct-mu{fill:var(--muted);color:var(--muted)}165+.cs-box{stroke:currentColor;stroke-width:1}.cs-gl{stroke:var(--grid);stroke-width:1}.cs-gap{stroke:var(--bg);stroke-width:1.5}166+.ct-s0,.cf-0{fill:var(--s0)}.ct-s1,.cf-1{fill:var(--s1)}.ct-s2,.cf-2{fill:var(--s2)}.ct-s3,.cf-3{fill:var(--s3)}.ct-s4,.cf-4{fill:var(--s4)}.ct-s5,.cf-5{fill:var(--s5)}167+.cs-0{stroke:var(--s0)}.cs-1{stroke:var(--s1)}.cs-2{stroke:var(--s2)}.cs-3{stroke:var(--s3)}.cs-4{stroke:var(--s4)}.cs-5{stroke:var(--s5)}168+.status{white-space:pre;color:var(--muted);padding:0 9px;height:21px}169+`@@ -0,0 +1,32 @@
1+package fileio2+3+import "github.com/FelineStateMachine/012/internal/sheet"4+5+// SnapChart is a chart with the values it draws, read when the6+// snapshot is taken.7+type SnapChart struct {8+ sheet.Chart9+ Values sheet.ChartData10+}11+12+// snapDrawn adds what the screen draws over the cells of snap's range:13+// the looks of the cells the sheet's rules touch, and the charts14+// anchored in the range, or all of them for a whole sheet. Looks cost a cell's rules, and only sheets15+// with rules have any.16+func snapDrawn(snap *Snapshot, s *sheet.Sheet, whole bool) {17+ if s.HasRules() {18+ for a := range snap.Cells {19+ if l := s.Look(a); l != (sheet.Look{}) {20+ if snap.Looks == nil {21+ snap.Looks = map[sheet.Addr]sheet.Look{}22+ }23+ snap.Looks[a] = l24+ }25+ }26+ }27+ for _, c := range s.Charts() {28+ if whole || snap.Range.Contains(c.At) {29+ snap.Charts = append(snap.Charts, SnapChart{Chart: c, Values: s.ChartData(c)})30+ }31+ }32+}@@ -60,6 +60,12 @@ type Snapshot struct {
6060 // Tables are the tables wholly in the range, for formats that keep6161 // them (XLSX).6262 Tables []sheet.Table63+64+ // Looks are how the sheet's rules draw the range's cells, and Charts65+ // the charts whose top-left cell is in it, for formats that show66+ // what the screen shows (HTML); see snapdrawn.go.67+ Looks map[sheet.Addr]sheet.Look68+ Charts []SnapChart6369 }6470 6571 // SnapName is a named range: its name, and the range on the sheet@@ -142,6 +148,7 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
142148 Tables: s.NamedTables(a),143149 }144150 }151+ snapDrawn(snap, s, whole)145152 return snap146153 }147154 @@ -0,0 +1,151 @@
1+package headless2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// ChartSpec is a chart to add, as Insert > Chart makes one: over Data,11+// with the header row, category labels and title guessed as Sheets12+// guesses them unless given.13+type ChartSpec struct {14+ Data string `json:"data" jsonschema:"the range to chart, e.g. Q3!A1:C9"`15+ Type string `json:"type,omitempty" jsonschema:"column, bar, line, pie, area or scatter; column when empty"`16+ Title string `json:"title,omitempty" jsonschema:"the title; guessed from the series when empty"`17+ At string `json:"at,omitempty" jsonschema:"the cell under the chart's top-left corner; right of the data when empty"`18+ Width int `json:"width,omitempty" jsonschema:"the width in terminal cells, 20 to 240; 60 when 0"`19+ Height int `json:"height,omitempty" jsonschema:"the height in terminal cells, 8 to 120; 18 when 0"`20+ ByRow bool `json:"by_row,omitempty" jsonschema:"series run along rows rather than down columns"`21+ Stack string `json:"stack,omitempty" jsonschema:"stacked or percent, for column, bar and area charts"`22+}23+24+// ChartMade says which chart AddChart made: its sheet and number, as25+// describe lists it.26+type ChartMade struct {27+ Sheet string `json:"sheet"`28+ Number int `json:"number"`29+ Title string `json:"title"`30+}31+32+// AddChart adds a chart as spec says.33+func AddChart(w *sheet.Workbook, spec ChartSpec) (ChartMade, error) {34+ t, err := Resolve(w, spec.Data)35+ if err != nil {36+ return ChartMade{}, err37+ }38+ s, r := t.Sheet, targetRect(t)39+ c := s.GuessChart(r)40+ if spec.Type != "" {41+ typ, ok := sheet.ParseChartType(spec.Type)42+ if !ok {43+ return ChartMade{}, fmt.Errorf("no chart type %q: column, bar, line, pie, area or scatter", spec.Type)44+ }45+ c.Type = typ46+ }47+ if spec.Title != "" {48+ c.Title = spec.Title49+ }50+ c.ByRow = spec.ByRow51+ if c.Stack, err = parseStack(spec.Stack); err != nil {52+ return ChartMade{}, err53+ }54+ c.W, c.H = orDefault(spec.Width, 60), orDefault(spec.Height, 18)55+ c.At = sheet.Addr{Col: min(r.To.Col+2, sheet.MaxCols-1), Row: r.From.Row}56+ if spec.At != "" {57+ cs, a, err := ResolveCell(w, spec.At)58+ if err != nil {59+ return ChartMade{}, err60+ }61+ if cs != s {62+ return ChartMade{}, fmt.Errorf("a chart sits on its data's sheet, %s", s.Name())63+ }64+ c.At = a65+ }66+ i := s.AddChart(c)67+ return ChartMade{Sheet: s.Name(), Number: i + 1, Title: ChartTitle(s.Charts()[i])}, nil68+}69+70+func parseStack(s string) (sheet.ChartStack, error) {71+ switch strings.ToLower(s) {72+ case "", "none":73+ return sheet.StackNone, nil74+ case "stacked":75+ return sheet.StackNormal, nil76+ case "percent":77+ return sheet.StackPercent, nil78+ }79+ return 0, fmt.Errorf("stack %q: stacked or percent", s)80+}81+82+func orDefault(v, d int) int {83+ if v <= 0 {84+ return d85+ }86+ return v87+}88+89+// PivotSpec is a pivot table to add, as Insert > Pivot table makes one:90+// a new sheet summarizing Source, whose first row names its columns.91+type PivotSpec struct {92+ Source string `json:"source" jsonschema:"the data, header row included, e.g. Sales!A1:F99"`93+ Name string `json:"name,omitempty" jsonschema:"the new sheet's name; Pivot Table N when empty"`94+ Rows []string `json:"rows,omitempty" jsonschema:"columns whose values become rows, by header or letter"`95+ Columns []string `json:"columns,omitempty" jsonschema:"columns whose values become columns, by header or letter"`96+ Values []PivotValue `json:"values" jsonschema:"columns to summarize"`97+ Totals bool `json:"totals,omitempty" jsonschema:"add grand totals"`98+}99+100+// PivotValue is a column to summarize, and how.101+type PivotValue struct {102+ Column string `json:"column" jsonschema:"by header or letter"`103+ Summarize string `json:"summarize,omitempty" jsonschema:"sum, counta, count, countunique, average, max or min; sum when empty"`104+}105+106+// AddPivot adds a pivot table's sheet after its source's, returning its107+// name.108+func AddPivot(w *sheet.Workbook, spec PivotSpec) (string, error) {109+ t, err := Resolve(w, spec.Source)110+ if err != nil {111+ return "", err112+ }113+ src, r := t.Sheet, targetRect(t)114+ p := sheet.Pivot{RowTotals: spec.Totals, ColumnTotals: spec.Totals}115+ for _, name := range spec.Rows {116+ col, err := columnOf(src, r, name, true)117+ if err != nil {118+ return "", err119+ }120+ p.Rows = append(p.Rows, sheet.PivotGroup{Col: col})121+ }122+ for _, name := range spec.Columns {123+ col, err := columnOf(src, r, name, true)124+ if err != nil {125+ return "", err126+ }127+ p.Columns = append(p.Columns, sheet.PivotGroup{Col: col})128+ }129+ if len(spec.Values) == 0 {130+ return "", fmt.Errorf("name a column to summarize")131+ }132+ for _, v := range spec.Values {133+ col, err := columnOf(src, r, v.Column, true)134+ if err != nil {135+ return "", err136+ }137+ sum := sheet.SumBy138+ if v.Summarize != "" {139+ var ok bool140+ if sum, ok = sheet.ParseSummarize(strings.ToLower(v.Summarize)); !ok {141+ return "", fmt.Errorf("summarize %q: sum, counta, count, countunique, average, max or min", v.Summarize)142+ }143+ }144+ p.Values = append(p.Values, sheet.PivotValue{Col: col, Summarize: sum})145+ }146+ s, err := w.CreatePivot(src, r, spec.Name, p)147+ if err != nil {148+ return "", err149+ }150+ return s.Name(), nil151+}@@ -0,0 +1,47 @@
1+package headless2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// FindChart finds a chart of s by its number, 1 for the first in the12+// order the sheet keeps them, or by its title, ignoring case.13+func FindChart(s *sheet.Sheet, which string) (int, error) {14+ charts := s.Charts()15+ if n, err := strconv.Atoi(strings.TrimSpace(which)); err == nil {16+ if n < 1 || n > len(charts) {17+ return 0, fmt.Errorf("%s has %d %s: chart %d isn't one", s.Name(), len(charts), plural(len(charts), "chart", "charts"), n)18+ }19+ return n - 1, nil20+ }21+ var titles []string22+ for i, c := range charts {23+ if strings.EqualFold(ChartTitle(c), which) {24+ return i, nil25+ }26+ titles = append(titles, ChartTitle(c))27+ }28+ if len(titles) == 0 {29+ return 0, fmt.Errorf("%s has no charts", s.Name())30+ }31+ return 0, fmt.Errorf("%s has no chart titled %q (charts: %s)", s.Name(), which, strings.Join(titles, ", "))32+}33+34+// ChartTitle is a chart's title, or its type's name when it has none.35+func ChartTitle(c sheet.Chart) string {36+ if c.Title != "" {37+ return c.Title38+ }39+ return c.Type.Title() + " chart"40+}41+42+func plural(n int, one, many string) string {43+ if n == 1 {44+ return one45+ }46+ return many47+}@@ -0,0 +1,241 @@
1+package headless2+3+import (4+ "github.com/FelineStateMachine/012/internal/notebook"5+ "github.com/FelineStateMachine/012/internal/sheet"6+)7+8+// Description is what a workbook holds, for 012 describe and the MCP9+// describe tool: enough for agents (or people) to know which10+// references to read and write without reading every cell. Its JSON11+// form is a stable schema (docs/reference/json.md): fields are only12+// added, never renamed or removed.13+type Description struct {14+ Sheets []SheetDescription `json:"sheets"`15+ Names []NameDescription `json:"names"`16+}17+18+// SheetDescription is one tab: a sheet or a notebook.19+type SheetDescription struct {20+ Name string `json:"name"`21+ Kind string `json:"kind"` // "sheet", "notebook" or "pivot"22+ Shown bool `json:"shown"`23+ Hidden bool `json:"hidden"`24+ // Used is the range from A1 to the last cell with contents, "" when25+ // the sheet is empty; Rows and Cols are its size.26+ Used string `json:"used"`27+ Rows int `json:"rows"`28+ Cols int `json:"cols"`29+ Cells int `json:"cells"`30+ Formulas int `json:"formulas"` // -1 on sheets too large to count them31+ Errors int `json:"errors"` // formulas showing errors; -1 when not counted32+ // Header is the row guessed to name the used range's columns, and33+ // Columns its names; 0 and none when no row looks like one.34+ Header int `json:"header_row"`35+ Columns []string `json:"columns"`36+ Frozen [2]int `json:"frozen"` // rows, columns37+ Filter string `json:"filter"` // the filtered range, or ""38+ Tables []TableDescription `json:"tables"`39+ Regions []RegionDescription `json:"regions"`40+ Charts []ChartDescription `json:"charts"`41+ Pivot *PivotDescription `json:"pivot,omitempty"`42+ Notebook []NotebookCell `json:"notebook_cells,omitempty"`43+}44+45+// NameDescription is a named range.46+type NameDescription struct {47+ Name string `json:"name"`48+ Range string `json:"range"` // with its sheet: Q3!B2:B9, or #REF!49+}50+51+// TableDescription is a named table.52+type TableDescription struct {53+ Name string `json:"name"`54+ Range string `json:"range"` // header row included55+ Columns []string `json:"columns"`56+}57+58+// RegionDescription is a linked file or a notebook cell's output sent to59+// the sheet.60+type RegionDescription struct {61+ Name string `json:"name"`62+ Kind string `json:"kind"` // "output" or "linked"63+ Range string `json:"range"`64+ File string `json:"file,omitempty"` // a linked region's file65+}66+67+// ChartDescription is a chart: Number is what 012 export --chart and68+// the MCP tools call it.69+type ChartDescription struct {70+ Number int `json:"number"`71+ Title string `json:"title"`72+ Type string `json:"type"`73+ Data string `json:"data"`74+ At string `json:"at"`75+}76+77+// PivotDescription is where a pivot table's data comes from.78+type PivotDescription struct {79+ Source string `json:"source"` // Sheet!A1:D9980+}81+82+// NotebookCell is one cell of a notebook tab.83+type NotebookCell struct {84+ Number int `json:"number"`85+ Kind string `json:"kind"` // "code" or "note"86+ Name string `json:"name,omitempty"`87+ Source string `json:"source"`88+ // State is "ran", "failed" or "not run"; Error says why it failed.89+ State string `json:"state,omitempty"`90+ Error string `json:"error,omitempty"`91+}92+93+// maxCounted is the most cells Describe counts formulas and errors on.94+const maxCounted = 1_000_00095+96+// Describe describes the workbook as it is.97+func Describe(w *sheet.Workbook) Description {98+ d := Description{Sheets: []SheetDescription{}, Names: []NameDescription{}}99+ for i, s := range w.Sheets() {100+ d.Sheets = append(d.Sheets, describeSheet(s, i == w.Active()))101+ }102+ for _, n := range w.Names() {103+ r := "#REF!"104+ if !n.Gone() {105+ r = sheet.Qualified(n.Sheet.Name(), n.Range)106+ }107+ d.Names = append(d.Names, NameDescription{Name: n.Name, Range: r})108+ }109+ return d110+}111+112+func describeSheet(s *sheet.Sheet, shown bool) SheetDescription {113+ d := SheetDescription{Name: s.Name(), Kind: "sheet", Shown: shown, Hidden: s.Hidden(), Cells: s.Len(),114+ Tables: []TableDescription{}, Regions: []RegionDescription{}, Charts: []ChartDescription{}, Columns: []string{}}115+ if used, ok := s.UsedRange(); ok {116+ d.Used, d.Rows, d.Cols = used.String(), used.To.Row+1, used.To.Col+1117+ d.Header, d.Columns = guessHeader(s, used)118+ }119+ d.Formulas, d.Errors = countFormulas(s)120+ d.Frozen[0], d.Frozen[1] = s.Frozen()121+ if f := s.Filter(); f != nil {122+ d.Filter = f.Range.String()123+ }124+ for _, t := range s.Tables() {125+ d.Tables = append(d.Tables, TableDescription{Name: t.Name, Range: t.Range.String(), Columns: append([]string{}, t.Cols...)})126+ }127+ for _, r := range s.Regions() {128+ d.Regions = append(d.Regions, describeRegion(s, r))129+ }130+ for i, c := range s.Charts() {131+ d.Charts = append(d.Charts, ChartDescription{Number: i + 1, Title: ChartTitle(c), Type: c.Type.String(),132+ Data: c.Data.String(), At: c.At.String()})133+ }134+ if p, ok := s.Pivot(); ok {135+ d.Kind, d.Pivot = "pivot", &PivotDescription{Source: sheet.Qualified(p.Source, p.Range)}136+ }137+ if s.IsNotebook() {138+ d.Kind, d.Notebook = "notebook", describeNotebook(s)139+ }140+ return d141+}142+143+// countFormulas counts the sheet's formulas and those showing errors,144+// or returns -1 for both past maxCounted cells.145+func countFormulas(s *sheet.Sheet) (formulas, errors int) {146+ if s.Len() > maxCounted {147+ return -1, -1148+ }149+ for _, a := range s.Addrs() {150+ if c := s.Cell(a); c != nil && c.IsFormula() {151+ formulas++152+ if s.Value(a).Kind == sheet.Error {153+ errors++154+ }155+ }156+ }157+ return formulas, errors158+}159+160+func describeRegion(s *sheet.Sheet, r sheet.Region) RegionDescription {161+ d := RegionDescription{Name: r.Name, Kind: "output", Range: r.At.String()}162+ if rng, ok := s.RegionTable(r.Name); ok {163+ d.Range = rng.String()164+ }165+ if r.Linked() {166+ d.Kind, d.File = "linked", r.File.Path167+ }168+ return d169+}170+171+func describeNotebook(s *sheet.Sheet) []NotebookCell {172+ out := []NotebookCell{}173+ for i, c := range s.NotebookCells() {174+ nc := NotebookCell{Number: i + 1, Kind: "code", Name: c.Name(), Source: c.Source}175+ if c.Kind != notebook.Code {176+ nc.Kind = "note"177+ out = append(out, nc)178+ continue179+ }180+ switch o := s.Book().Output(c.ID); {181+ case o == nil || o.Unsaved:182+ nc.State = "not run"183+ case o.Failed():184+ nc.State, nc.Error = "failed", o.Err185+ default:186+ nc.State = "ran"187+ }188+ out = append(out, nc)189+ }190+ return out191+}192+193+// headerLook is how many rows below a header guessHeader reads.194+const headerLook = 5195+196+// guessHeader finds the row naming the used range's columns: its first197+// row with contents, when every cell there is text and the rows below198+// hold something other than text, or the row is bold. It returns the199+// row's number (1 for the first) and the names, or 0.200+func guessHeader(s *sheet.Sheet, used sheet.Rect) (int, []string) {201+ first, ok := s.FilledBounds(used)202+ if !ok {203+ return 0, []string{}204+ }205+ row := first.From.Row206+ names := make([]string, 0, used.To.Col-used.From.Col+1)207+ bold, n := true, 0208+ for col := used.From.Col; col <= used.To.Col; col++ {209+ a := sheet.Addr{Col: col, Row: row}210+ v := s.Value(a)211+ switch v.Kind {212+ case sheet.Empty:213+ names = append(names, "")214+ continue215+ case sheet.Text:216+ default:217+ return 0, []string{}218+ }219+ names = append(names, v.Str)220+ bold = bold && s.CellStyle(a).Bold221+ n++222+ }223+ if n == 0 || row == used.To.Row || !bold && !typedBelow(s, used, row) {224+ return 0, []string{}225+ }226+ return row + 1, names227+}228+229+// typedBelow reports whether the rows below row hold a number, a date,230+// a boolean or a formula.231+func typedBelow(s *sheet.Sheet, used sheet.Rect, row int) bool {232+ for r := row + 1; r <= min(row+headerLook, used.To.Row); r++ {233+ for col := used.From.Col; col <= used.To.Col; col++ {234+ a := sheet.Addr{Col: col, Row: r}235+ if k := s.Value(a).Kind; k != sheet.Empty && k != sheet.Text {236+ return true237+ }238+ }239+ }240+ return false241+}@@ -0,0 +1,86 @@
1+package headless2+3+import (4+ "bytes"5+ "encoding/json"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// TestDescribeFixture describes the workbook of every feature the13+// release fixtures keep.14+func TestDescribeFixture(t *testing.T) {15+ f, err := Open("../sheet/testdata/fixtures/new/workbook.012", false)16+ if err != nil {17+ t.Fatal(err)18+ }19+ d := Describe(f.Book)20+ var text bytes.Buffer21+ if err := WriteDescription(&text, d); err != nil {22+ t.Fatal(err)23+ }24+ for _, want := range []string{25+ "Sales (shown) A1:J13, 13 rows x 10 columns",26+ " header row 1: Date, Region, Units, Price, Total",27+ " chart 1 Units by date column of A1:C9 at K2",28+ "Lists (hidden)",29+ " table Targets D1:E3 Region, Target",30+ " pivot table of Sales!A1:E9",31+ " output files A1:B3",32+ " linked app F2 following logs/app.csv",33+ "Notebook (notebook) 5 cells",34+ " 4 code $files | nope (failed: Command `nope` not found)",35+ " Units Sales!C2:C9",36+ } {37+ if !strings.Contains(text.String(), want) {38+ t.Errorf("text lacks %q:\n%s", want, text.String())39+ }40+ }41+ var js bytes.Buffer42+ if err := Encode(&js, "json", d); err != nil {43+ t.Fatal(err)44+ }45+ var back Description46+ if err := json.Unmarshal(js.Bytes(), &back); err != nil {47+ t.Fatal(err)48+ }49+ if len(back.Sheets) != len(d.Sheets) || back.Sheets[0].Charts[2].Type != "pie" || back.Sheets[4].Notebook[1].Name != "files" {50+ t.Errorf("JSON round trip lost something: %+v", back.Sheets[0])51+ }52+ var nu bytes.Buffer53+ if err := Encode(&nu, "nuon", d); err != nil || !strings.HasPrefix(nu.String(), "{sheets: [") {54+ t.Errorf("NUON: %v %.40s", err, nu.String())55+ }56+}57+58+// TestGuessHeader takes a first row of text over typed rows, or a bold59+// one, as a header, and not a table of text.60+func TestGuessHeader(t *testing.T) {61+ for _, c := range []struct {62+ rows [][]string63+ bold bool64+ want int65+ }{66+ {[][]string{{"Item", "Price"}, {"Apple", "1.5"}}, false, 1},67+ {[][]string{{"Item", "Kind"}, {"Apple", "fruit"}}, false, 0},68+ {[][]string{{"Item", "Kind"}, {"Apple", "fruit"}}, true, 1},69+ {[][]string{{"2024", "2025"}, {"1", "2"}}, false, 0},70+ {[][]string{{"Only", "row"}}, true, 0},71+ } {72+ s := sheet.New()73+ for r, row := range c.rows {74+ for col, in := range row {75+ s.Set(sheet.Addr{Col: col, Row: r}, in)76+ }77+ }78+ if c.bold {79+ s.SetStyle(sheet.Rect{To: sheet.Addr{Col: 1}}, func(st *sheet.Style) { st.Bold = true })80+ }81+ used, _ := s.UsedRange()82+ if got, _ := guessHeader(s, used); got != c.want {83+ t.Errorf("%v bold %v: header %d, want %d", c.rows, c.bold, got, c.want)84+ }85+ }86+}@@ -0,0 +1,107 @@
1+package headless2+3+import (4+ "fmt"5+ "io"6+ "strings"7+)8+9+// WriteDescription writes d as text: a line for each sheet saying what10+// it holds, with what's on it indented below, then the named ranges.11+func WriteDescription(w io.Writer, d Description) error {12+ var b strings.Builder13+ for _, s := range d.Sheets {14+ describeSheetText(&b, s)15+ }16+ if len(d.Names) > 0 {17+ b.WriteString("names\n")18+ for _, n := range d.Names {19+ fmt.Fprintf(&b, " %s %s\n", n.Name, n.Range)20+ }21+ }22+ _, err := io.WriteString(w, b.String())23+ return err24+}25+26+func describeSheetText(b *strings.Builder, s SheetDescription) {27+ var tags []string28+ if s.Shown {29+ tags = append(tags, "shown")30+ }31+ if s.Hidden {32+ tags = append(tags, "hidden")33+ }34+ if s.Kind != "sheet" {35+ tags = append(tags, s.Kind)36+ }37+ b.WriteString(s.Name)38+ if len(tags) > 0 {39+ b.WriteString(" (" + strings.Join(tags, ", ") + ")")40+ }41+ switch {42+ case s.Kind == "notebook":43+ fmt.Fprintf(b, " %d %s\n", len(s.Notebook), plural(len(s.Notebook), "cell", "cells"))44+ case s.Used == "":45+ b.WriteString(" empty\n")46+ default:47+ fmt.Fprintf(b, " %s, %d %s x %d %s, %d %s%s\n", s.Used, s.Rows, plural(s.Rows, "row", "rows"), s.Cols,48+ plural(s.Cols, "column", "columns"), s.Cells, plural(s.Cells, "cell", "cells"), formulaCount(s))49+ }50+ describeParts(b, s)51+}52+53+// formulaCount says how many formulas there are and how many show54+// errors.55+func formulaCount(s SheetDescription) string {56+ if s.Formulas <= 0 {57+ return ""58+ }59+ out := fmt.Sprintf(", %d %s", s.Formulas, plural(s.Formulas, "formula", "formulas"))60+ if s.Errors > 0 {61+ out += fmt.Sprintf(" (%d showing errors)", s.Errors)62+ }63+ return out64+}65+66+// describeParts writes what's on a sheet, a line each.67+func describeParts(b *strings.Builder, s SheetDescription) {68+ if s.Header > 0 {69+ fmt.Fprintf(b, " header row %d: %s\n", s.Header, strings.Join(s.Columns, ", "))70+ }71+ if s.Frozen != [2]int{} {72+ fmt.Fprintf(b, " frozen %d %s, %d %s\n", s.Frozen[0], plural(s.Frozen[0], "row", "rows"), s.Frozen[1], plural(s.Frozen[1], "column", "columns"))73+ }74+ if s.Filter != "" {75+ fmt.Fprintf(b, " filter %s\n", s.Filter)76+ }77+ if s.Pivot != nil {78+ fmt.Fprintf(b, " pivot table of %s\n", s.Pivot.Source)79+ }80+ for _, t := range s.Tables {81+ fmt.Fprintf(b, " table %s %s %s\n", t.Name, t.Range, strings.Join(t.Columns, ", "))82+ }83+ for _, r := range s.Regions {84+ if r.Kind == "linked" {85+ fmt.Fprintf(b, " linked %s %s following %s\n", r.Name, r.Range, r.File)86+ } else {87+ fmt.Fprintf(b, " output %s %s\n", r.Name, r.Range)88+ }89+ }90+ for _, c := range s.Charts {91+ fmt.Fprintf(b, " chart %d %s %s of %s at %s\n", c.Number, c.Title, c.Type, c.Data, c.At)92+ }93+ for _, c := range s.Notebook {94+ line := []rune(strings.SplitN(c.Source, "\n", 2)[0])95+ if len(line) > 60 {96+ line = append(line[:57], []rune("...")...)97+ }98+ state := ""99+ switch {100+ case c.Error != "":101+ state = " (failed: " + c.Error + ")"102+ case c.State != "":103+ state = " (" + c.State + ")"104+ }105+ fmt.Fprintf(b, " %d %s %s%s\n", c.Number, c.Kind, string(line), state)106+ }107+}@@ -0,0 +1,33 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+ "io"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+)10+11+// Encode writes v, a value of this package's result types, as JSON12+// (indented, one value) or NUON, the forms --format json and nuon ask13+// for. NUON is the JSON read as nushell reads it, so both carry the14+// same schema (docs/reference/json.md).15+func Encode(w io.Writer, format string, v any) error {16+ data, err := json.MarshalIndent(v, "", " ")17+ if err != nil {18+ return err19+ }20+ switch format {21+ case "json":22+ case "nuon":23+ val, err := nuon.Parse(data)24+ if err != nil {25+ return err26+ }27+ data = nuon.Append(nil, val)28+ default:29+ return fmt.Errorf("can't write %s", format)30+ }31+ _, err = w.Write(append(data, '\n'))32+ return err33+}@@ -22,7 +22,13 @@ const maxJEVRounds = 16
2222 // workbook's formulas ask, a few questions at a time, until none is2323 // waiting, each within timeout. It returns how many were asked.2424 func AnswerJEV(ctx context.Context, w *sheet.Workbook, client jev.Client, timeout time.Duration) int {25- cache := jev.NewCache()25+ return AnswerJEVFrom(ctx, w, client, jev.NewCache(), timeout)26+}27+28+// AnswerJEVFrom is AnswerJEV with the answers kept in cache, which a29+// caller opening the workbook again and again (012 mcp) keeps, so each30+// question is asked once.31+func AnswerJEVFrom(ctx context.Context, w *sheet.Workbook, client jev.Client, cache *jev.Cache, timeout time.Duration) int {2632 w.SetRemote(cache)2733 asked := 02834 for range maxJEVRounds {@@ -0,0 +1,182 @@
1+package headless2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Operation is one change of an Apply: what the MCP apply_operations11+// tool takes, each the same engine call a menu command makes. Op says12+// which, and the other fields are its arguments:13+//14+// set Ref (one cell) and Input, as 012 set types it15+// clear Ref: the range's contents, keeping formats and notes16+// insert_rows before Ref's first row, Count rows (Ref's rows when 0)17+// delete_rows Ref's rows18+// insert_columns before Ref's first column, Count columns19+// delete_columns Ref's columns20+// add_sheet Name, after the last sheet21+// rename_sheet Ref (a sheet) to Name22+// delete_sheet Ref (a sheet)23+// define_name Name for the range Ref24+// sort Ref's rows by Keys, the first row left in place with Header25+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"`33+}34+35+// SortKey is a column to sort by: a letter (B) or, with a header, the36+// header's text.37+type SortKey struct {38+ Column string `json:"column" jsonschema:"a column letter (B) or the header's text"`39+ Descending bool `json:"descending,omitempty"`40+}41+42+// Apply makes ops as one change, in order, stopping at the first that43+// fails with an error naming it; the workbook should then be thrown44+// away rather than saved, as after Set.45+func Apply(w *sheet.Workbook, ops []Operation, o SetOptions) (warnings []string, err error) {46+ err = w.Batch(sheet.Change{Label: "apply operations", Sheet: w.Sheet(w.Active())}, func() error {47+ for i, op := range ops {48+ warn, err := applyOne(w, op, o)49+ if err != nil {50+ return fmt.Errorf("operation %d (%s): %w", i+1, op.Op, err)51+ }52+ if warn != "" {53+ warnings = append(warnings, warn)54+ }55+ }56+ return nil57+ })58+ return warnings, err59+}60+61+func applyOne(w *sheet.Workbook, op Operation, o SetOptions) (string, error) {62+ switch op.Op {63+ case "set":64+ return setOne(w, Entry{Ref: op.Ref, Input: op.Input}, o)65+ case "add_sheet":66+ _, err := w.AddSheet(op.Name, w.Len())67+ return "", err68+ case "define_name":69+ t, err := Resolve(w, op.Ref)70+ if err != nil {71+ return "", err72+ }73+ return "", w.DefineName(op.Name, t.Sheet, targetRect(t))74+ case "rename_sheet", "delete_sheet":75+ s := w.Lookup(strings.TrimSuffix(op.Ref, "!"))76+ if s == nil {77+ return "", noSheet(w, op.Ref)78+ }79+ if op.Op == "rename_sheet" {80+ return "", w.RenameSheet(s, op.Name)81+ }82+ return "", w.DeleteSheet(s)83+ case "sort":84+ return "", Sort(w, op.Ref, op.Keys, op.Header)85+ }86+ return "", rangeOp(w, op, o)87+}88+89+// rangeOp makes the operations on a range: clearing it, and inserting90+// and deleting its rows or columns.91+func rangeOp(w *sheet.Workbook, op Operation, o SetOptions) error {92+ t, err := Resolve(w, op.Ref)93+ if err != nil {94+ return err95+ }96+ s, r := t.Sheet, targetRect(t)97+ rows, cols := r.To.Row-r.From.Row+1, r.To.Col-r.From.Col+198+ if op.Count > 0 {99+ rows, cols = op.Count, op.Count100+ }101+ switch op.Op {102+ case "clear":103+ if p, ok := s.Protecting(r); ok && !o.Force {104+ return fmt.Errorf("%s is protected (%s): force clears it anyway", t, p.Label())105+ }106+ s.EraseRange(r)107+ return nil108+ case "insert_rows":109+ return s.InsertRows(r.From.Row, rows)110+ case "delete_rows":111+ s.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)112+ return nil113+ case "insert_columns":114+ return s.InsertCols(r.From.Col, cols)115+ case "delete_columns":116+ s.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)117+ return nil118+ }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)120+}121+122+// targetRect is the target's range: a whole sheet's is A1 to its last123+// cell with contents.124+func targetRect(t Target) sheet.Rect {125+ if t.Whole {126+ r, _ := t.Sheet.UsedRange()127+ return r128+ }129+ return t.Range130+}131+132+// Sort sorts the rows of the range ref names by keys, as Data > Sort133+// range does, leaving its first row in place when header is set.134+func Sort(w *sheet.Workbook, ref string, keys []SortKey, header bool) error {135+ t, err := Resolve(w, ref)136+ if err != nil {137+ return err138+ }139+ r := targetRect(t)140+ if len(keys) == 0 {141+ return fmt.Errorf("name a column to sort %s by", t)142+ }143+ var sk []sheet.SortKey144+ for _, k := range keys {145+ col, err := columnOf(t.Sheet, r, k.Column, header)146+ if err != nil {147+ return err148+ }149+ sk = append(sk, sheet.SortKey{Col: col, Desc: k.Descending})150+ }151+ if header {152+ r.From.Row++153+ }154+ if len(t.Sheet.MergesIn(r)) > 0 {155+ return fmt.Errorf("%s has merged cells, which can't be sorted", t)156+ }157+ t.Sheet.SortRange(r, sk)158+ return nil159+}160+161+// columnOf finds a column of r by its letter, or with a header row by162+// the header's text, ignoring case.163+func columnOf(s *sheet.Sheet, r sheet.Rect, name string, header bool) (int, error) {164+ if header {165+ for col := r.From.Col; col <= r.To.Col; col++ {166+ if v := s.Value(sheet.Addr{Col: col, Row: r.From.Row}); v.Kind == sheet.Text && strings.EqualFold(v.Str, name) {167+ return col, nil168+ }169+ }170+ }171+ if a, ok := sheet.ParseAddr(strings.ToUpper(name) + "1"); ok && a.Row == 0 && a.Col >= r.From.Col && a.Col <= r.To.Col {172+ return a.Col, nil173+ }174+ return 0, fmt.Errorf("%q isn't a column of %s: name it by its letter%s", name, r, headerHint(header))175+}176+177+func headerHint(header bool) string {178+ if header {179+ return " or its header"180+ }181+ return ""182+}@@ -0,0 +1,87 @@
1+package headless2+3+import (4+ "encoding/json"5+6+ "github.com/FelineStateMachine/012/internal/fileio"7+ "github.com/FelineStateMachine/012/internal/nuon"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Range is a range's values as a grid, for agents: what ReadRange12+// returns and the MCP read_range tool gives back. Its JSON form is a13+// stable schema (docs/reference/json.md).14+type Range struct {15+ // Range is what was read, with its sheet: Q3!A1:D9; a whole sheet is16+ // read from A1 to its last cell with contents.17+ Range string `json:"range"`18+ Rows int `json:"rows"`19+ 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.25+ Text [][]string `json:"text,omitempty"`26+ // Formulas are the formulas in the range by cell (B7: =SUM(B1:B6)).27+ Formulas map[string]string `json:"formulas"`28+ // Truncated is set when the range held more than the most cells a29+ // read returns; Rows says how many rows came back.30+ Truncated bool `json:"truncated"`31+}32+33+// ReadOptions tune ReadRange.34+type ReadOptions struct {35+ MaxCells int // the most cells to return, whole rows at a time; 0 is 10,00036+ Text bool // also return each cell as shown37+}38+39+// ReadRange reads t's cells as a grid.40+func ReadRange(t Target, o ReadOptions) Range {41+ if o.MaxCells <= 0 {42+ o.MaxCells = 10_00043+ }44+ s, r := t.Sheet, t.Range45+ if t.Whole {46+ r, _ = s.UsedRange()47+ } else if data, ok := s.FilledBounds(r); ok {48+ r.To.Row, r.To.Col = min(r.To.Row, data.To.Row), min(r.To.Col, data.To.Col)49+ } else {50+ r.To = r.From51+ }52+ cols := r.To.Col - r.From.Col + 153+ rows := r.To.Row - r.From.Row + 154+ out := Range{Range: sheet.Qualified(s.Name(), r), Cols: cols, Formulas: map[string]string{}}55+ if rows*cols > o.MaxCells {56+ rows, out.Truncated = max(o.MaxCells/cols, 1), true57+ }58+ out.Rows = rows59+ null := json.RawMessage("null")60+ for row := r.From.Row; row < r.From.Row+rows; row++ {61+ vals := make([]json.RawMessage, cols)62+ var text []string63+ if o.Text {64+ text = make([]string, cols)65+ }66+ for i := range cols {67+ a := sheet.Addr{Col: r.From.Col + i, Row: row}68+ vals[i] = null69+ if c := s.Cell(a); c != nil && c.IsFormula() {70+ out.Formulas[a.String()] = c.Input71+ }72+ v := s.Value(a)73+ if v.Kind == sheet.Empty {74+ continue75+ }76+ vals[i] = nuon.AppendJSON(nil, fileio.CellValue(fileio.SnapCell{Value: v, Format: s.DisplayFormat(a)}))77+ if o.Text {78+ text[i] = s.LocalText(a)79+ }80+ }81+ out.Values = append(out.Values, vals)82+ if o.Text {83+ out.Text = append(out.Text, text)84+ }85+ }86+ return out87+}@@ -0,0 +1,108 @@
1+package headless2+3+import (4+ "bytes"5+ "encoding/json"6+ "os"7+8+ "github.com/FelineStateMachine/012/internal/diff"9+ "github.com/FelineStateMachine/012/internal/nuon"10+)11+12+// The results commands write with --format json or nuon, and the MCP13+// tools return. Each is a stable schema (docs/reference/json.md): fields14+// are only added, never renamed or removed.15+16+// Change is one change as 012 diff --format json lists it: what kind17+// of thing changed (cell, sheet, region, name, macro or a layout18+// field), on which sheet, which item, which field of it, and its old19+// and new values, null where it wasn't or isn't there.20+type Change struct {21+ Kind string `json:"kind"`22+ Sheet string `json:"sheet"`23+ Item string `json:"item"`24+ Field string `json:"field"`25+ Old json.RawMessage `json:"old"`26+ New json.RawMessage `json:"new"`27+}28+29+// Changes are diff's changes as records.30+func Changes(changes []diff.Change) []Change {31+ out := make([]Change, 0, len(changes))32+ for _, c := range changes {33+ out = append(out, Change{Kind: c.Kind, Sheet: c.Sheet, Item: c.Item, Field: c.Field,34+ Old: nuon.AppendJSON(nil, c.Old), New: nuon.AppendJSON(nil, c.New)})35+ }36+ return out37+}38+39+// SetResult is what 012 set writes: the file, whether it was saved (not40+// with --dry-run), what changed, and the warnings of entries a rule41+// marks invalid.42+type SetResult struct {43+ File string `json:"file"`44+ Saved bool `json:"saved"`45+ Changes []Change `json:"changes"`46+ Warnings []string `json:"warnings"`47+}48+49+// ProblemRecord is a formula showing an error.50+type ProblemRecord struct {51+ Cell string `json:"cell"` // with its sheet: Q3!B752+ Sheet string `json:"sheet"`53+ Addr string `json:"addr"`54+ Value string `json:"value"` // #DIV/0!55+ Why string `json:"why"`56+ JEV bool `json:"jev"` // a JEV function's, answered with --jev57+}58+59+// Problems are problems as records.60+func Problems(ps []Problem) []ProblemRecord {61+ out := make([]ProblemRecord, 0, len(ps))62+ for _, p := range ps {63+ out = append(out, ProblemRecord{Cell: p.At(), Sheet: p.Sheet, Addr: p.Addr.String(), Value: p.Value, Why: p.Why, JEV: p.JEV})64+ }65+ return out66+}67+68+// RecalcResult is what 012 recalc writes.69+type RecalcResult struct {70+ File string `json:"file"`71+ Errors []ProblemRecord `json:"errors"`72+ Circular bool `json:"circular"`73+ // NotebookFailures are the notebook cells --notebooks couldn't run.74+ NotebookFailures []string `json:"notebook_failures"`75+}76+77+// ExportResult is what 012 export --report writes.78+type ExportResult struct {79+ File string `json:"file"`80+ Format string `json:"format"`81+ Rows int `json:"rows"`82+ Notes []string `json:"notes"`83+}84+85+// Changes compares the workbook as it is with its file as it was86+// opened (nothing, for a new file), before it's saved.87+func (f *File) Changes() ([]diff.Change, error) {88+ var old []byte89+ if !f.New {90+ var err error91+ if old, err = os.ReadFile(f.Path); err != nil {92+ return nil, err93+ }94+ }95+ var now bytes.Buffer96+ if err := f.Book.Write(&now); err != nil {97+ return nil, err98+ }99+ a, err := diff.Read(old)100+ if err != nil {101+ return nil, err102+ }103+ b, err := diff.Read(now.Bytes())104+ if err != nil {105+ return nil, err106+ }107+ return diff.Compare(a, b), nil108+}@@ -0,0 +1,55 @@
1+package headless2+3+import (4+ "context"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/notebook"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// CellRun is what running a notebook cell gave: its state, its output12+// as NUON (cut at maxShownOutput), or why it failed.13+type CellRun struct {14+ Notebook string `json:"notebook"`15+ Cell int `json:"cell"`16+ Name string `json:"name"`17+ State string `json:"state"` // "ran" or "failed"18+ Error string `json:"error"`19+ Output string `json:"output"` // NUON20+ Cut bool `json:"cut"` // Output was longer and is cut21+}22+23+// maxShownOutput is the most of an output CellRun returns.24+const maxShownOutput = 64 << 1025+26+// RunCell runs cell n (1 for the first) of the notebook tab named, as27+// Run does on the screen: its output replaces the one kept and goes on28+// to the sheet it was sent to. Whether it may run at all (trust, the29+// shell setting) is the caller's to decide first.30+func RunCell(ctx context.Context, w *sheet.Workbook, name string, n int, o NotebookOptions) (CellRun, error) {31+ s := w.Lookup(name)32+ if s == nil || !s.IsNotebook() {33+ return CellRun{}, fmt.Errorf("no notebook named %q", name)34+ }35+ cells := s.NotebookCells()36+ if n < 1 || n > len(cells) {37+ return CellRun{}, fmt.Errorf("%s has %d cells: cell %d isn't one", name, len(cells), n)38+ }39+ c := cells[n-1]40+ if c.Kind != notebook.Code {41+ return CellRun{}, fmt.Errorf("%s cell %d is a note, which doesn't run", name, n)42+ }43+ out := CellRun{Notebook: s.Name(), Cell: n, Name: c.Name(), State: "ran"}44+ if err := runCell(ctx, w, s, cells, n-1, 1, o); err != nil {45+ out.State, out.Error = "failed", err.Error()46+ return out, nil47+ }48+ if res := w.Output(c.ID); res != nil {49+ out.Output = string(res.NUON)50+ if len(out.Output) > maxShownOutput {51+ out.Output, out.Cut = out.Output[:maxShownOutput], true52+ }53+ }54+ return out, nil55+}@@ -11,7 +11,8 @@ import (
1111 // in it, as the file stores entries (numbers and dates in en-US's form,1212 // formulas with commas, whatever the workbook's locale); "" clears it.1313 type Entry struct {14- Ref, Input string14+ 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"`1516 }1617 1718 // SetOptions tune Set.@@ -0,0 +1,177 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// FilterColumn is one column's criteria for Filter: a condition (as12+// Data > Create a filter's "Filter by condition" names them) with its13+// value, and values to hide ("Filter by values").14+type FilterColumn struct {15+ Column string `json:"column" jsonschema:"a column letter (B) or the header's text"`16+ Condition string `json:"condition,omitempty" jsonschema:"empty, not_empty, contains, not_contains, starts_with, ends_with, exactly, gt, ge, lt, le, eq or ne"`17+ Value string `json:"value,omitempty" jsonschema:"what the condition compares with, as typed: 100, 2026-01-31, North"`18+ Hide []string `json:"hide,omitempty" jsonschema:"values to hide, as the cells show them"`19+}20+21+// Filter puts a filter on the range ref names (its first row the22+// header) with the columns' criteria, replacing the sheet's filter, as23+// Data > Create a filter does; remove takes the sheet's filter off.24+func Filter(w *sheet.Workbook, ref string, cols []FilterColumn, remove bool) error {25+ t, err := Resolve(w, ref)26+ if err != nil {27+ return err28+ }29+ s := t.Sheet30+ if remove {31+ s.RemoveFilter()32+ return nil33+ }34+ r := targetRect(t)35+ if f := s.Filter(); f == nil || f.Range != r {36+ s.CreateFilter(r)37+ }38+ for _, c := range cols {39+ col, err := columnOf(s, r, c.Column, true)40+ if err != nil {41+ return err42+ }43+ cr := sheet.Criteria{Hidden: c.Hide}44+ if c.Condition != "" {45+ op, ok := sheet.ParseCondOp(c.Condition)46+ if !ok {47+ return fmt.Errorf("no condition %q: empty, not_empty, contains, not_contains, starts_with, ends_with, exactly, gt, ge, lt, le, eq or ne", c.Condition)48+ }49+ cr.Cond = sheet.Condition{Op: op, Arg: sheet.CondArg(op, c.Value, s.Locale())}50+ }51+ s.FilterColumn(col, cr)52+ }53+ return nil54+}55+56+// FindOptions tune Find.57+type FindOptions struct {58+ Ref string `json:"ref,omitempty" jsonschema:"a sheet or range to search; every sheet when empty"`59+ MatchCase bool `json:"match_case,omitempty"`60+ WholeCell bool `json:"whole_cell,omitempty" jsonschema:"the whole cell must match"`61+ Regex bool `json:"regex,omitempty" jsonschema:"the query is a regular expression"`62+ InFormulas bool `json:"in_formulas,omitempty" jsonschema:"search formulas' text rather than their values"`63+ Limit int `json:"limit,omitempty" jsonschema:"the most matches to return; 100 when 0"`64+}65+66+// Match is a cell Find found: where, what it shows and what was typed.67+type Match struct {68+ Cell string `json:"cell"`69+ Text string `json:"text"`70+ Input string `json:"input"`71+}72+73+// Found is what Find returns: the first matches, and how many there74+// are in all.75+type Found struct {76+ Matches []Match `json:"matches"`77+ Total int `json:"total"`78+}79+80+// Find searches the workbook's sheets, or the sheet or range o.Ref81+// names, as Edit > Find and replace does.82+func Find(w *sheet.Workbook, query string, o FindOptions) (Found, error) {83+ if o.Limit <= 0 {84+ o.Limit = 10085+ }86+ sheets, within := w.Sheets(), (*sheet.Rect)(nil)87+ if o.Ref != "" {88+ t, err := Resolve(w, o.Ref)89+ if err != nil {90+ return Found{}, err91+ }92+ sheets = []*sheet.Sheet{t.Sheet}93+ if !t.Whole {94+ r := t.Range95+ within = &r96+ }97+ }98+ out := Found{Matches: []Match{}}99+ for _, s := range sheets {100+ if s.IsNotebook() {101+ continue102+ }103+ addrs, err := s.Find(query, sheet.FindOptions{MatchCase: o.MatchCase, WholeCell: o.WholeCell, Regex: o.Regex,104+ InFormulas: o.InFormulas, Within: within})105+ if err != nil {106+ return Found{}, err107+ }108+ out.Total += len(addrs)109+ for _, a := range addrs {110+ if len(out.Matches) == o.Limit {111+ break112+ }113+ m := Match{Cell: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a}), Text: s.LocalText(a)}114+ if c := s.Cell(a); c != nil {115+ m.Input = c.Input116+ }117+ out.Matches = append(out.Matches, m)118+ }119+ }120+ return out, nil121+}122+123+// Evaluated is a formula's result, computed without keeping it.124+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"`130+}131+132+// Evaluate computes formula as if typed in the cell at names (a cell133+// below the shown sheet's data when ""), and returns what it shows.134+// It changes w: call it on a workbook to throw away.135+func Evaluate(w *sheet.Workbook, formula, at string) (Evaluated, error) {136+ if !strings.HasPrefix(formula, "=") {137+ formula = "=" + formula138+ }139+ s, a, err := evalCell(w, at)140+ if err != nil {141+ return Evaluated{}, err142+ }143+ out := Evaluated{Cell: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a})}144+ if err := w.Batch(sheet.Change{Label: "evaluate", Sheet: s}, func() error { return setFormula(s, a, formula) }); err != nil {145+ return out, err146+ }147+ res := ReadRange(Target{Sheet: s, Range: sheet.Rect{From: a, To: a}}, ReadOptions{})148+ out.Value, out.Text = res.Values[0][0], s.LocalText(a)149+ if v := s.Value(a); v.Kind == sheet.Error {150+ out.Error = s.ExplainError(a)151+ }152+ if area, ok := s.SpillArea(a); ok && area != (sheet.Rect{From: a, To: a}) {153+ spill := ReadRange(Target{Sheet: s, Range: area}, ReadOptions{MaxCells: 1000})154+ out.Spill = &spill155+ }156+ return out, nil157+}158+159+// evalCell is where Evaluate computes: the cell at names, or the first160+// cell of the column A two rows below the shown sheet's data.161+func evalCell(w *sheet.Workbook, at string) (*sheet.Sheet, sheet.Addr, error) {162+ if at != "" {163+ return ResolveCell(w, at)164+ }165+ s := w.Sheet(w.Active())166+ row := 0167+ if used, ok := s.UsedRange(); ok {168+ row = min(used.To.Row+2, sheet.MaxRows-1)169+ }170+ return s, sheet.Addr{Row: row}, nil171+}172+173+// setFormula types formula in a, with a parse error said as Set says it.174+func setFormula(s *sheet.Sheet, a sheet.Addr, formula string) error {175+ _, err := setOne(s.Book(), Entry{Ref: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a}), Input: formula}, SetOptions{Force: true})176+ return err177+}@@ -0,0 +1,100 @@
1+package mcp2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "sync"8+9+ "github.com/FelineStateMachine/012/internal/diff"10+ "github.com/FelineStateMachine/012/internal/headless"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Backend is where the tools' workbook lives. The tools only read it,15+// work on a copy thrown away, or change it through the workbook's one16+// mutation path (Batch), so the file backend here and a live session's17+// (a workbook open on the screen, changed as a participant's operations)18+// can stand behind the same tools.19+type Backend interface {20+ // Name is what the workbook is called, for titles: its file's name.21+ Name() string22+ // View runs fn on the workbook as it is, which fn must not change.23+ View(ctx context.Context, fn func(*sheet.Workbook) error) error24+ // Scratch runs fn on a copy of the workbook that is thrown away.25+ Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error26+ // Change runs fn on the workbook inside one Batch, labelled label,27+ // and returns what it changed. The change is kept unless fn fails or28+ // dryRun is set; a failed change leaves the workbook as it was.29+ Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error)30+}31+32+// FileBackend is a workbook file, opened afresh for every call so edits33+// made meanwhile by others (the screen, git, 012 set) are seen, and34+// saved atomically after each change, as 012 set saves it.35+type FileBackend struct {36+ Path string37+ // Prepare, when set, runs on the workbook once opened, before fn:38+ // what the server's flags allow (answering JEV functions).39+ Prepare func(context.Context, *headless.File) error40+ // Save writes a changed workbook; nil is File.Save.41+ Save func(*headless.File) error42+43+ mu sync.Mutex // one call at a time, so changes don't overlap44+}45+46+// Name is the file's name without its folder.47+func (b *FileBackend) Name() string {48+ return strings.TrimSuffix(filepath.Base(b.Path), filepath.Ext(b.Path))49+}50+51+// open opens the file, a missing one as an empty workbook saved on the52+// first change, as 012 set creates it.53+func (b *FileBackend) open(ctx context.Context) (*headless.File, error) {54+ f, err := headless.Open(b.Path, true)55+ if err != nil {56+ return nil, err57+ }58+ if b.Prepare != nil {59+ if err := b.Prepare(ctx, f); err != nil {60+ return nil, err61+ }62+ }63+ return f, nil64+}65+66+func (b *FileBackend) View(ctx context.Context, fn func(*sheet.Workbook) error) error {67+ b.mu.Lock()68+ defer b.mu.Unlock()69+ f, err := b.open(ctx)70+ if err != nil {71+ return err72+ }73+ return fn(f.Book)74+}75+76+// Scratch is View: every call opens its own copy of the file.77+func (b *FileBackend) Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error {78+ return b.View(ctx, fn)79+}80+81+func (b *FileBackend) Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error) {82+ b.mu.Lock()83+ defer b.mu.Unlock()84+ f, err := b.open(ctx)85+ if err != nil {86+ return nil, err87+ }88+ w := f.Book89+ if err := w.Batch(sheet.Change{Label: label, Sheet: w.Sheet(w.Active())}, func() error { return fn(w) }); err != nil {90+ return nil, err91+ }92+ changes, err := f.Changes()93+ if err != nil || dryRun || len(changes) == 0 {94+ return changes, err95+ }96+ if b.Save != nil {97+ return changes, b.Save(f)98+ }99+ return changes, f.Save()100+}@@ -0,0 +1,60 @@
1+package mcp2+3+import (4+ "context"5+ "strings"6+7+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"8+)9+10+// Prompts are starting points a host offers people (as slash commands11+// in Claude Code): each a message asking the model to do a common job12+// with the tools, the way the tools are best used.13+var prompts = []struct {14+ prompt *sdk.Prompt15+ text string // with {arg} for each argument16+}{{17+ prompt: &sdk.Prompt{Name: "summarize_workbook", Title: "Summarize the workbook",18+ Description: "What each sheet holds and what its numbers say"},19+ text: "Call describe, then read the main ranges it points at with read_range (a few hundred rows at most). " +20+ "Summarize what each sheet holds, how the sheets relate (formulas between them, tables, pivots, charts), and what the numbers say. Don't change anything.",21+}, {22+ prompt: &sdk.Prompt{Name: "fix_errors", Title: "Fix formula errors",23+ Description: "Find the formulas showing errors, explain them and fix them"},24+ text: "Call list_errors. For each formula showing an error, read the cells it reads, and explain the cause. " +25+ "Propose a fix, check it with evaluate at the same cell, and show it with write_cells and dry_run. Make the fixes once they're agreed.",26+}, {27+ prompt: &sdk.Prompt{Name: "add_column", Title: "Add a computed column",28+ Description: "A new column of formulas beside a table of data",29+ Arguments: []*sdk.PromptArgument{30+ {Name: "range", Description: "the data, e.g. Sales!A1:F99, or a table's name", Required: true},31+ {Name: "column", Description: "what the new column computes, e.g. profit as revenue minus cost", Required: true},32+ }},33+ text: "Add a column to {range} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +34+ "Write the header, then the formula for the first data row: check it with evaluate at that cell, then write every row's formula with write_cells (dry_run first), relative references moving with the row. " +35+ "Follow the columns' number formats. Finish with list_errors.",36+}, {37+ prompt: &sdk.Prompt{Name: "chart", Title: "Chart some data",38+ Description: "A chart that answers a question about a range",39+ Arguments: []*sdk.PromptArgument{40+ {Name: "range", Description: "the data, e.g. Sales!A1:C13", Required: true},41+ {Name: "question", Description: "what the chart should show, e.g. how sales grew by month"},42+ }},43+ text: "Read {range} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +44+ "If the data needs summarizing first, use create_pivot and chart its result. Create it with create_chart, with a title that says what it shows.",45+}}46+47+func (s *Server) addPrompts() {48+ for _, p := range prompts {49+ text := p.text50+ s.AddPrompt(p.prompt, func(_ context.Context, req *sdk.GetPromptRequest) (*sdk.GetPromptResult, error) {51+ msg := text52+ for k, v := range req.Params.Arguments {53+ msg = strings.ReplaceAll(msg, "{"+k+"}", v)54+ }55+ msg = strings.ReplaceAll(msg, "{question}", "the data's main point")56+ return &sdk.GetPromptResult{Description: req.Params.Name,57+ Messages: []*sdk.PromptMessage{{Role: "user", Content: &sdk.TextContent{Text: msg}}}}, nil58+ })59+ }60+}@@ -0,0 +1,103 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/headless"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The tools that read the workbook, or compute on a copy thrown away.13+14+type describeIn struct{}15+16+type readIn struct {17+ Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`18+ Text bool `json:"text,omitempty" jsonschema:"also return each cell as the sheet shows it ($1,200.00, 9/29/2026)"`19+ Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`20+}21+22+type evaluateIn struct {23+ Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`24+ At string `json:"at,omitempty" jsonschema:"the cell it's computed in, which relative references are relative to; below the shown sheet's data when empty"`25+}26+27+type findIn struct {28+ Query string `json:"query" jsonschema:"what to look for"`29+ headless.FindOptions30+}31+32+type errorsIn struct{}33+34+// errorsOut lists the formulas showing errors.35+type errorsOut struct {36+ Errors []headless.ProblemRecord `json:"errors"`37+ Circular bool `json:"circular"`38+}39+40+var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: ptr(false)}41+42+func ptr[T any](v T) *T { return &v }43+44+func (s *Server) addReadTools() {45+ tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly,46+ Description: "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."},47+ func(ctx context.Context, _ *sdk.CallToolRequest, _ describeIn) (*sdk.CallToolResult, headless.Description, error) {48+ var d headless.Description49+ err := s.b.View(ctx, func(w *sheet.Workbook) error { d = headless.Describe(w); return nil })50+ return nil, d, err51+ })52+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: s.viewMeta(),53+ 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."},54+ s.readRange)55+ tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,56+ 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."},57+ func(ctx context.Context, _ *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {58+ var out headless.Evaluated59+ err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {60+ var err error61+ out, err = headless.Evaluate(w, in.Formula, in.At)62+ return err63+ })64+ return nil, out, err65+ })66+ tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,67+ Description: "Find cells by what they show (or their formulas' text), as Edit > Find does: the cells, their text and what was typed in them."},68+ func(ctx context.Context, _ *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {69+ var out headless.Found70+ err := s.b.View(ctx, func(w *sheet.Workbook) error {71+ var err error72+ out, err = headless.Find(w, in.Query, in.FindOptions)73+ return err74+ })75+ return nil, out, err76+ })77+ tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,78+ Description: "The formulas showing errors after recalculating everything, with what the screen says about each (as 012 recalc lists them), without saving."},79+ func(ctx context.Context, _ *sdk.CallToolRequest, _ errorsIn) (*sdk.CallToolResult, errorsOut, error) {80+ var out errorsOut81+ err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {82+ problems, circular := headless.Recalc(w)83+ out = errorsOut{Errors: headless.Problems(problems), Circular: circular}84+ return nil85+ })86+ return nil, out, err87+ })88+}89+90+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, headless.Range, error) {91+ var out headless.Range92+ var res *sdk.CallToolResult93+ err := s.b.View(ctx, func(w *sheet.Workbook) error {94+ t, err := headless.Resolve(w, in.Ref)95+ if err != nil {96+ return err97+ }98+ out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})99+ res = s.rangeView(req, t)100+ return nil101+ })102+ return res, out, err103+}@@ -0,0 +1,225 @@
1+package mcp2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "strconv"8+ "strings"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+12+ "github.com/FelineStateMachine/012/internal/headless"13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// Resources are the workbook's data by address, for hosts that let18+// people attach them to a conversation:19+//20+// o12://book/Sheet1!A1:D40 a range, as read_range returns it (JSON)21+// o12://book/table/Sales a table, header row included (JSON)22+// o12://book/notebook/Notes/2 a notebook cell: its source and output23+//24+// Sheet names are percent-encoded. Each sheet's used range, each table25+// and each code cell are listed; the templates reach any range. The26+// scheme is o12 because a URI's scheme starts with a letter, and the27+// workbook is the authority, book, so a range's colon is in the path.28+const (29+ scheme = "o12://book/"30+ tablePrefix = scheme + "table/"31+ notebookPrefix = scheme + "notebook/"32+ maxListed = 20033+)34+35+func (s *Server) addResources() {36+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{sheet}!{+range}", Name: "range", MIMEType: "application/json",37+ Description: "A range of a sheet as rows of values, as read_range returns it; the sheet's name percent-encoded"}, s.readResource)38+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: tablePrefix + "{name}", Name: "table", MIMEType: "application/json",39+ Description: "A named table, header row included"}, s.readResource)40+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: notebookPrefix + "{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",41+ Description: "A notebook cell's source, and its output as NUON or why it failed"}, s.readResource)42+}43+44+// refreshResources lists the workbook's resources as it stands,45+// telling clients when the list changes.46+func (s *Server) refreshResources(ctx context.Context) {47+ var want []sdk.Resource48+ _ = s.b.View(ctx, func(w *sheet.Workbook) error {49+ want = listResources(w)50+ return nil51+ })52+ s.mu.Lock()53+ defer s.mu.Unlock()54+ keep := map[string]bool{}55+ for _, r := range want {56+ keep[r.URI] = true57+ if !s.resources[r.URI] {58+ res := r59+ s.AddResource(&res, s.readResource)60+ }61+ }62+ var gone []string63+ for uri := range s.resources {64+ if !keep[uri] {65+ gone = append(gone, uri)66+ }67+ }68+ if len(gone) > 0 {69+ s.RemoveResources(gone...)70+ }71+ s.resources = keep72+}73+74+func listResources(w *sheet.Workbook) []sdk.Resource {75+ var out []sdk.Resource76+ add := func(r sdk.Resource) {77+ if len(out) < maxListed {78+ out = append(out, r)79+ }80+ }81+ for _, sh := range w.Sheets() {82+ if sh.IsNotebook() {83+ for i, c := range sh.NotebookCells() {84+ if c.Kind == notebook.Code {85+ add(sdk.Resource{URI: fmt.Sprintf("%s%s/%d", notebookPrefix, escape(sh.Name()), i+1), Name: fmt.Sprintf("%s cell %d", sh.Name(), i+1),86+ MIMEType: "text/plain", Description: firstLine(c.Source)})87+ }88+ }89+ continue90+ }91+ if used, ok := sh.UsedRange(); ok {92+ add(sdk.Resource{URI: rangeURI(sh.Name(), used), Name: sh.Name(), Title: sh.Name() + " " + used.String(),93+ MIMEType: "application/json", Description: "The used range of " + sh.Name()})94+ }95+ for _, t := range sh.Tables() {96+ add(sdk.Resource{URI: tablePrefix + escape(t.Name), Name: "table " + t.Name, MIMEType: "application/json",97+ Description: "Table " + t.Name + " on " + sh.Name() + ", " + t.Range.String() + ": " + strings.Join(t.Cols, ", ")})98+ }99+ }100+ return out101+}102+103+// rangeURI is the resource of a range of a sheet.104+func rangeURI(sheetName string, r sheet.Rect) string {105+ return scheme + escape(sheetName) + "!" + r.String()106+}107+108+func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {109+ uri := req.Params.URI110+ var text, mime string111+ err := s.b.View(ctx, func(w *sheet.Workbook) error {112+ var err error113+ text, mime, err = resourceText(w, uri)114+ return err115+ })116+ if err != nil {117+ return nil, err118+ }119+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: uri, MIMEType: mime, Text: text}}}, nil120+}121+122+// resourceText is what a resource's URI names, and its type.123+func resourceText(w *sheet.Workbook, uri string) (string, string, error) {124+ switch {125+ case strings.HasPrefix(uri, notebookPrefix):126+ return notebookCellText(w, strings.TrimPrefix(uri, notebookPrefix))127+ case strings.HasPrefix(uri, tablePrefix):128+ name, err := unescape(strings.TrimPrefix(uri, tablePrefix))129+ if err != nil {130+ return "", "", err131+ }132+ return rangeJSON(w, name)133+ case strings.HasPrefix(uri, scheme):134+ rest := strings.TrimPrefix(uri, scheme)135+ i := strings.LastIndex(rest, "!")136+ if i < 0 {137+ return "", "", sdk.ResourceNotFoundError(uri)138+ }139+ name, err := unescape(rest[:i])140+ if err != nil {141+ return "", "", err142+ }143+ return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rest[i+1:])144+ }145+ return "", "", sdk.ResourceNotFoundError(uri)146+}147+148+func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {149+ t, err := headless.Resolve(w, ref)150+ if err != nil {151+ return "", "", err152+ }153+ data, err := json.Marshal(headless.ReadRange(t, headless.ReadOptions{}))154+ return string(data), "application/json", err155+}156+157+// notebookCellText is a cell's source, then its output or why it158+// failed; path is sheet/cell.159+func notebookCellText(w *sheet.Workbook, path string) (string, string, error) {160+ i := strings.LastIndex(path, "/")161+ if i < 0 {162+ return "", "", fmt.Errorf("a notebook cell's resource is %snotebook/cell", notebookPrefix)163+ }164+ name, err := unescape(path[:i])165+ if err != nil {166+ return "", "", err167+ }168+ n, err := strconv.Atoi(path[i+1:])169+ s := w.Lookup(name)170+ if s == nil || !s.IsNotebook() || err != nil || n < 1 || n > len(s.NotebookCells()) {171+ return "", "", fmt.Errorf("no cell %s of a notebook %q", path[i+1:], name)172+ }173+ c := s.NotebookCells()[n-1]174+ var b strings.Builder175+ b.WriteString(c.Source + "\n")176+ switch o := w.Output(c.ID); {177+ case c.Kind != notebook.Code:178+ case o == nil || o.Unsaved:179+ b.WriteString("\n(not run)\n")180+ case o.Failed():181+ b.WriteString("\nfailed: " + o.Err + "\n")182+ default:183+ b.WriteString("\n" + string(o.NUON) + "\n")184+ }185+ return b.String(), "text/plain", nil186+}187+188+func firstLine(s string) string {189+ line, _, _ := strings.Cut(s, "\n")190+ return line191+}192+193+// escape percent-encodes all but URI's unreserved characters, so a194+// sheet's name fits a template's simple variable.195+func escape(s string) string {196+ var b strings.Builder197+ for _, c := range []byte(s) {198+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~", c) >= 0 {199+ b.WriteByte(c)200+ } else {201+ fmt.Fprintf(&b, "%%%02X", c)202+ }203+ }204+ return b.String()205+}206+207+func unescape(s string) (string, error) {208+ var b strings.Builder209+ for i := 0; i < len(s); i++ {210+ if s[i] != '%' {211+ b.WriteByte(s[i])212+ continue213+ }214+ if i+2 >= len(s) {215+ return "", fmt.Errorf("%q: a %% needs two hex digits", s)216+ }217+ v, err := strconv.ParseUint(s[i+1:i+3], 16, 8)218+ if err != nil {219+ return "", fmt.Errorf("%q: a %% needs two hex digits", s)220+ }221+ b.WriteByte(byte(v))222+ i += 2223+ }224+ return b.String(), nil225+}@@ -0,0 +1,96 @@
1+// Package mcp is 012 mcp: a Model Context Protocol server on one2+// workbook (see docs/agents/mcp.md). Its tools, resources and prompts3+// are a thin layer over internal/headless, the same code 012 get, set4+// and describe run, so agents' changes go through the workbook's one5+// mutation path (Batch) and the same checks as a person's, and are6+// shown by 012 diff. The workbook stands behind a Backend: a file here,7+// and for live mode a session.8+package mcp9+10+import (11+ "context"12+ "encoding/json"13+ "reflect"14+ "sync"15+16+ "github.com/google/jsonschema-go/jsonschema"17+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"18+19+ "github.com/FelineStateMachine/012/internal/headless"20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// Options say what the server may do beyond reading and writing cells.24+type Options struct {25+ // Version is 012's, which the server reports.26+ Version string27+ // ReadOnly leaves out every tool that changes the workbook.28+ ReadOnly bool29+ // Force lets writes change protected ranges, as 012 set --force.30+ Force bool31+ // Notebooks, when set, lets run_notebook_cell run cells with nu, as32+ // --notebooks does; nil leaves the tool out. MayRun says why a33+ // workbook's cells may not run here (trust, the shell setting), and34+ // may mark it trusted, as the command line's --trust does.35+ Notebooks *headless.NotebookOptions36+ MayRun func(*sheet.Workbook) error37+}38+39+// Server is the MCP server on a backend.40+type Server struct {41+ *sdk.Server42+ b Backend43+ opts Options44+45+ mu sync.Mutex46+ resources map[string]bool // the URIs listed now47+}48+49+// New makes the server with its tools, resources and prompts.50+func New(b Backend, o Options) *Server {51+ s := &Server{b: b, opts: o, resources: map[string]bool{}}52+ s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)},53+ &sdk.ServerOptions{Instructions: instructions, Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}})54+ s.addReadTools()55+ if !o.ReadOnly {56+ s.addWriteTools()57+ }58+ s.addResources()59+ s.addPrompts()60+ s.addViews()61+ s.refreshResources(context.Background())62+ return s63+}64+65+func orDevel(v string) string {66+ if v == "" {67+ return "(devel)"68+ }69+ return v70+}71+72+// instructions tell the model how to use the server.73+const instructions = `This server works on one 012 spreadsheet workbook (.012 file).74+Call describe first: it lists the sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks.75+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.76+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'.77+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.`78+79+// rawSchemas infer json.RawMessage, a value already in JSON, as any80+// value.81+var rawSchemas = &jsonschema.ForOptions{TypeSchemas: map[reflect.Type]*jsonschema.Schema{82+ reflect.TypeFor[json.RawMessage](): {},83+}}84+85+// tool adds a tool whose handler takes In and returns Out, with the86+// output schema inferred as rawSchemas says.87+func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.CallToolRequest, In) (*sdk.CallToolResult, Out, error)) {88+ if t.OutputSchema == nil {89+ schema, err := jsonschema.For[Out](rawSchemas)90+ if err != nil {91+ panic(err)92+ }93+ t.OutputSchema = schema94+ }95+ sdk.AddTool(s.Server, t, h)96+}@@ -0,0 +1,340 @@
1+package mcp2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "errors"8+ "io"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "testing"14+15+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"16+17+ "github.com/FelineStateMachine/012/internal/headless"18+ "github.com/FelineStateMachine/012/internal/notebook"19+ "github.com/FelineStateMachine/012/internal/nushell"20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// bookFile writes a workbook: a sheet of sales with a header, and a24+// second sheet whose name has a space.25+func bookFile(t *testing.T) string {26+ t.Helper()27+ w := sheet.NewBook()28+ s := w.Sheet(0)29+ rows := [][]string{{"Region", "Units", "Price"}, {"North", "3", "12.5"}, {"South", "5", "9.75"}, {"North", "7", "12.5"}}30+ for r, row := range rows {31+ for c, in := range row {32+ s.Set(sheet.Addr{Col: c, Row: r}, in)33+ }34+ }35+ other, _ := w.AddSheet("Q3 plan", 1)36+ other.Set(sheet.Addr{}, "=Sheet1!B2*2")37+ path := filepath.Join(t.TempDir(), "book.012")38+ writeBook(t, w, path)39+ return path40+}41+42+func writeBook(t *testing.T, w *sheet.Workbook, path string) {43+ t.Helper()44+ var b bytes.Buffer45+ if err := w.Write(&b); err != nil {46+ t.Fatal(err)47+ }48+ if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil {49+ t.Fatal(err)50+ }51+}52+53+// connect serves backend b with options o to a client over the SDK's54+// in-memory transport.55+func connect(t *testing.T, b Backend, o Options) *sdk.ClientSession {56+ t.Helper()57+ ctx := context.Background()58+ srv := New(b, o)59+ st, ct := sdk.NewInMemoryTransports()60+ ss, err := srv.Connect(ctx, st, nil)61+ if err != nil {62+ t.Fatal(err)63+ }64+ t.Cleanup(func() { ss.Close() })65+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, ct, nil)66+ if err != nil {67+ t.Fatal(err)68+ }69+ t.Cleanup(func() { cs.Close() })70+ return cs71+}72+73+// call calls a tool, failing on a protocol error, and decodes its74+// structured result into out.75+func call(t *testing.T, cs *sdk.ClientSession, name string, args any, out any) *sdk.CallToolResult {76+ t.Helper()77+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args})78+ if err != nil {79+ t.Fatalf("%s: %v", name, err)80+ }81+ if out != nil && !res.IsError {82+ data, _ := json.Marshal(res.StructuredContent)83+ if err := json.Unmarshal(data, out); err != nil {84+ t.Fatalf("%s: %v in %s", name, err, data)85+ }86+ }87+ return res88+}89+90+func errorText(res *sdk.CallToolResult) string {91+ if !res.IsError || len(res.Content) == 0 {92+ return ""93+ }94+ return res.Content[0].(*sdk.TextContent).Text95+}96+97+func TestTools(t *testing.T) {98+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})99+ var names []string100+ for tl, err := range cs.Tools(context.Background(), nil) {101+ if err != nil {102+ t.Fatal(err)103+ }104+ names = append(names, tl.Name)105+ }106+ for _, want := range []string{"describe", "read_range", "evaluate", "find", "list_errors", "write_cells", "apply_operations",107+ "sort", "filter", "create_chart", "create_pivot"} {108+ if !slices.Contains(names, want) {109+ t.Errorf("no tool %s in %v", want, names)110+ }111+ }112+ if slices.Contains(names, "run_notebook_cell") {113+ t.Error("run_notebook_cell offered without Notebooks")114+ }115+ ro := connect(t, &FileBackend{Path: bookFile(t)}, Options{ReadOnly: true})116+ for tl := range ro.Tools(context.Background(), nil) {117+ if !tl.Annotations.ReadOnlyHint {118+ t.Errorf("read-only server offers %s", tl.Name)119+ }120+ }121+}122+123+func TestDescribeAndRead(t *testing.T) {124+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})125+ var d headless.Description126+ call(t, cs, "describe", map[string]any{}, &d)127+ if len(d.Sheets) != 2 || d.Sheets[0].Header != 1 || !slices.Equal(d.Sheets[0].Columns, []string{"Region", "Units", "Price"}) {128+ t.Errorf("describe: %+v", d.Sheets)129+ }130+ var r headless.Range131+ call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "text": true}, &r)132+ if r.Range != "Sheet1!A1:C4" || r.Rows != 4 || string(r.Values[1][2]) != "12.5" || r.Text[0][0] != "Region" {133+ t.Errorf("read_range: %+v", r)134+ }135+ call(t, cs, "read_range", map[string]any{"ref": "'Q3 plan'!A1"}, &r)136+ if string(r.Values[0][0]) != "6" || r.Formulas["A1"] != "=Sheet1!B2*2" {137+ t.Errorf("read_range of a formula: %+v", r)138+ }139+ res := call(t, cs, "read_range", map[string]any{"ref": "Q4!A1"}, nil)140+ if !strings.Contains(errorText(res), `no sheet named "Q4" (sheets: Sheet1, Q3 plan)`) {141+ t.Errorf("bad sheet: %q", errorText(res))142+ }143+ var found headless.Found144+ call(t, cs, "find", map[string]any{"query": "north"}, &found)145+ if found.Total != 2 || found.Matches[1].Cell != "Sheet1!A4" {146+ t.Errorf("find: %+v", found)147+ }148+}149+150+func TestEvaluateLeavesTheFile(t *testing.T) {151+ path := bookFile(t)152+ before, _ := os.ReadFile(path)153+ cs := connect(t, &FileBackend{Path: path}, Options{})154+ var ev headless.Evaluated155+ call(t, cs, "evaluate", map[string]any{"formula": `SUMIF(A2:A4, "North", B2:B4)`}, &ev)156+ if string(ev.Value) != "10" || ev.Cell != "Sheet1!A6" {157+ t.Errorf("evaluate: %+v", ev)158+ }159+ call(t, cs, "evaluate", map[string]any{"formula": "=SEQUENCE(2)", "at": "E1"}, &ev)160+ if ev.Spill == nil || ev.Spill.Range != "Sheet1!E1:E2" {161+ t.Errorf("spill: %+v", ev.Spill)162+ }163+ call(t, cs, "evaluate", map[string]any{"formula": "=1/0"}, &ev)164+ if string(ev.Value) != `"#DIV/0!"` || ev.Error == "" {165+ t.Errorf("error: %+v", ev)166+ }167+ if res := call(t, cs, "evaluate", map[string]any{"formula": "=SUM(A1"}, nil); !strings.Contains(errorText(res), "Expected") {168+ t.Errorf("parse error: %q", errorText(res))169+ }170+ var errs errorsOut171+ call(t, cs, "list_errors", map[string]any{}, &errs)172+ if len(errs.Errors) != 0 {173+ t.Errorf("errors: %+v", errs)174+ }175+ if after, _ := os.ReadFile(path); !bytes.Equal(before, after) {176+ t.Error("reading tools changed the file")177+ }178+}179+180+func TestWrites(t *testing.T) {181+ path := bookFile(t)182+ cs := connect(t, &FileBackend{Path: path}, Options{})183+ before, _ := os.ReadFile(path)184+ var ch Changed185+ call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D1", "input": "Total"}, {"ref": "D2", "input": "=B2*C2"}},186+ "dry_run": true}, &ch)187+ if ch.Saved || len(ch.Changes) != 3 || ch.Changes[1].Item != "D2" || string(ch.Changes[2].New) != "37.5" {188+ t.Errorf("dry run: %+v", ch)189+ }190+ if after, _ := os.ReadFile(path); !bytes.Equal(before, after) {191+ t.Error("dry run saved")192+ }193+ call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D2", "input": "=B2*C2"}}}, &ch)194+ if !ch.Saved || len(ch.Changes) != 2 {195+ t.Errorf("write: %+v", ch)196+ }197+ f, _ := headless.Open(path, false)198+ if v := f.Book.Sheet(0).Value(sheet.Addr{Col: 3, Row: 1}); v.Num != 37.5 {199+ t.Errorf("D2 = %v", v)200+ }201+ res := call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D3", "input": "=SUM(A1"}}}, nil)202+ if !strings.Contains(errorText(res), "Sheet1!D3: Expected") {203+ t.Errorf("bad formula: %q", errorText(res))204+ }205+ call(t, cs, "apply_operations", map[string]any{"operations": []map[string]any{206+ {"op": "insert_rows", "ref": "A2"}, {"op": "set", "ref": "A2", "input": "East"}, {"op": "define_name", "ref": "B2:B5", "name": "Units"},207+ }}, &ch)208+ if !ch.Saved {209+ t.Errorf("operations: %+v", ch)210+ }211+ call(t, cs, "sort", map[string]any{"ref": "A1:C5", "header": true, "keys": []map[string]any{{"column": "Units", "descending": true}}}, &ch)212+ var r headless.Range213+ call(t, cs, "read_range", map[string]any{"ref": "Units"}, &r)214+ if got := string(r.Values[0][0]) + string(r.Values[1][0]); got != "75" {215+ t.Errorf("sorted units start %s: %+v", got, r.Values)216+ }217+ call(t, cs, "filter", map[string]any{"ref": "A1:C5", "columns": []map[string]any{{"column": "Region", "condition": "eq", "value": "North"}}}, &ch)218+ var d headless.Description219+ call(t, cs, "describe", map[string]any{}, &d)220+ if d.Sheets[0].Filter != "A1:C5" || len(d.Names) != 1 {221+ t.Errorf("filter or name missing: %+v", d)222+ }223+}224+225+func TestChartAndPivot(t *testing.T) {226+ path := bookFile(t)227+ cs := connect(t, &FileBackend{Path: path}, Options{})228+ var c chartOut229+ call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "type": "bar", "title": "Units"}, &c)230+ if c.Chart.Number != 1 || c.Chart.Sheet != "Sheet1" || !c.Saved {231+ t.Errorf("chart: %+v", c)232+ }233+ var p pivotOut234+ call(t, cs, "create_pivot", map[string]any{"source": "A1:C4", "rows": []string{"Region"}, "values": []map[string]any{{"column": "Units"}}}, &p)235+ if p.Sheet != "Pivot Table 1" || !p.Saved {236+ t.Errorf("pivot: %+v", p)237+ }238+ var r headless.Range239+ call(t, cs, "read_range", map[string]any{"ref": "'Pivot Table 1'!A1:B3"}, &r)240+ if string(r.Values[1][0]) != `"North"` || string(r.Values[1][1]) != "10" {241+ t.Errorf("pivot result: %+v", r.Values)242+ }243+ res := call(t, cs, "create_pivot", map[string]any{"source": "A1:C4", "values": []map[string]any{{"column": "Weight"}}}, nil)244+ if !strings.Contains(errorText(res), `"Weight" isn't a column of A1:C4`) {245+ t.Errorf("unknown column: %q", errorText(res))246+ }247+}248+249+func TestResourcesAndPrompts(t *testing.T) {250+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})251+ ctx := context.Background()252+ var uris []string253+ for r, err := range cs.Resources(ctx, nil) {254+ if err != nil {255+ t.Fatal(err)256+ }257+ uris = append(uris, r.URI)258+ }259+ if !slices.Contains(uris, "o12://book/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book/Q3%20plan!A1") {260+ t.Errorf("resources: %v", uris)261+ }262+ for uri, want := range map[string]string{263+ "o12://book/Sheet1!A1:C4": `"range":"Sheet1!A1:C4"`,264+ "o12://book/Q3%20plan!A1": `"formulas":{"A1":"=Sheet1!B2*2"}`,265+ "o12://book/Sheet1!B2:B3": `"values":[[3],[5]]`,266+ "o12://book/table/Missing": "isn't a cell",267+ } {268+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})269+ got := ""270+ if err != nil {271+ got = err.Error()272+ } else {273+ got = res.Contents[0].Text274+ }275+ if !strings.Contains(got, want) {276+ t.Errorf("%s: %s, want %s", uri, got, want)277+ }278+ }279+ var names []string280+ for p, err := range cs.Prompts(ctx, nil) {281+ if err != nil {282+ t.Fatal(err)283+ }284+ names = append(names, p.Name)285+ }286+ if len(names) != len(prompts) {287+ t.Errorf("prompts: %v", names)288+ }289+ res, err := cs.GetPrompt(ctx, &sdk.GetPromptParams{Name: "add_column", Arguments: map[string]string{"range": "Sheet1!A1:C4", "column": "revenue"}})290+ if err != nil {291+ t.Fatal(err)292+ }293+ if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 that computes revenue") {294+ t.Errorf("prompt: %s", text)295+ }296+}297+298+var errNotTrusted = errors.New("book.012 was saved on another computer")299+300+// fakeNu answers every cell with one table.301+type fakeNu struct{ ran int }302+303+func (f *fakeNu) Run(_ context.Context, _ nushell.Job, _ string, stdout io.Writer) error {304+ f.ran++305+ _, err := stdout.Write([]byte("[[name]; [a.txt]]"))306+ return err307+}308+309+func TestRunNotebookCell(t *testing.T) {310+ w := sheet.NewBook()311+ nb, _ := w.AddNotebook("Notes", w.Sheet(0))312+ nb.SetNotebookCells("add cell", []notebook.Cell{{Kind: notebook.Note, Source: "# Files"}, {Kind: notebook.Code, Source: "files = ls"}})313+ path := filepath.Join(t.TempDir(), "nb.012")314+ writeBook(t, w, path)315+ nu := &fakeNu{}316+ refuse := true317+ cs := connect(t, &FileBackend{Path: path}, Options{Notebooks: &headless.NotebookOptions{Runner: nu},318+ MayRun: func(*sheet.Workbook) error {319+ if refuse {320+ return errNotTrusted321+ }322+ return nil323+ }})324+ if res := call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 2}, nil); errorText(res) != errNotTrusted.Error() || nu.ran != 0 {325+ t.Errorf("untrusted: %q, ran %d", errorText(res), nu.ran)326+ }327+ refuse = false328+ var run headless.CellRun329+ call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 2}, &run)330+ if run.State != "ran" || run.Name != "files" || !strings.Contains(run.Output, "a.txt") {331+ t.Errorf("run: %+v", run)332+ }333+ res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://book/notebook/Notes/2"})334+ if err != nil || !strings.Contains(res.Contents[0].Text, "a.txt") {335+ t.Errorf("cell resource after the run: %v %+v", err, res)336+ }337+ if res := call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 1}, nil); !strings.Contains(errorText(res), "is a note") {338+ t.Errorf("note cell: %q", errorText(res))339+ }340+}@@ -0,0 +1,119 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/fileio"9+ "github.com/FelineStateMachine/012/internal/headless"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Views are the MCP Apps extension (io.modelcontextprotocol/ui, spec14+// 2026-01-26): read_range and create_chart name one UI resource,15+// ui://012/view, which hosts that support the extension draw in a16+// sandboxed frame beside the result. The resource is the HTML export's17+// page with nothing in it yet (its fonts, palettes and pointer script);18+// each result carries the fragment to show in its _meta, under viewKey,19+// only for clients that declared the extension. The page asks the host20+// for its theme, shows the fragment when the result arrives, and says21+// how tall it is.22+const (23+ uiExtension = "io.modelcontextprotocol/ui"24+ uiMIME = "text/html;profile=mcp-app"25+ viewURI = "ui://012/view"26+ viewKey = "o12/view"27+ // maxViewRows bounds the rows a range's view draws.28+ maxViewRows = 50029+)30+31+func (s *Server) addViews() {32+ meta := sdk.Meta{"ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}}}33+ s.AddResource(&sdk.Resource{URI: viewURI, Name: "012 view", Title: s.b.Name() + " in 012", MIMEType: uiMIME, Meta: meta,34+ Description: "The range or chart a tool returned, drawn as 012 draws it"},35+ func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {36+ page := fileio.HTMLDocument(s.b.Name(), fileio.HTMLPanel(s.b.Name(), "", "")+`<div id="o12-view"></div>`, viewScript)37+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: viewURI, MIMEType: uiMIME, Text: page, Meta: meta}}}, nil38+ })39+}40+41+// viewMeta is what a tool drawn in the view says of it: the resource,42+// under the key the spec names and the one its first drafts named.43+func (s *Server) viewMeta() sdk.Meta {44+ return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI}45+}46+47+// wantsViews reports whether the client declared the extension.48+func wantsViews(req *sdk.CallToolRequest) bool {49+ if req == nil || req.Session == nil {50+ return false51+ }52+ p := req.Session.InitializeParams()53+ if p == nil || p.Capabilities == nil {54+ return false55+ }56+ _, ok := p.Capabilities.Extensions[uiExtension]57+ return ok58+}59+60+// viewResult is a result carrying a fragment of the view.61+func viewResult(title, where, html string) *sdk.CallToolResult {62+ return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: map[string]any{"title": title, "where": where, "html": html}}}63+}64+65+// rangeView draws what read_range read, at most maxViewRows rows.66+func (s *Server) rangeView(req *sdk.CallToolRequest, t headless.Target) *sdk.CallToolResult {67+ if !wantsViews(req) {68+ return nil69+ }70+ r := t.Range71+ if t.Whole {72+ r, _ = t.Sheet.UsedRange()73+ }74+ r.To.Row = min(r.To.Row, r.From.Row+maxViewRows-1)75+ snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())76+ grid, _ := fileio.HTMLGrid(snap)77+ return viewResult(s.b.Name(), sheet.Qualified(t.Sheet.Name(), snap.Range), grid)78+}79+80+// chartView draws the chart create_chart made.81+func (s *Server) chartView(req *sdk.CallToolRequest, w *sheet.Workbook, made headless.ChartMade) *sdk.CallToolResult {82+ if !wantsViews(req) {83+ return nil84+ }85+ c, ok := fileio.ChartSnap(w.Lookup(made.Sheet), made.Number-1)86+ if !ok {87+ return nil88+ }89+ return viewResult(made.Title, made.Sheet+" "+c.Data.String(), fileio.HTMLChart(c))90+}91+92+// viewScript is the view's side of the extension's messages, over93+// postMessage with the host: ui/initialize, then the host's theme, the94+// tool's result and teardown, and the page's height as it changes.95+const viewScript = `(function(){96+var next=1,host=window.parent;97+function send(m){host.postMessage(Object.assign({jsonrpc:"2.0"},m),"*")}98+function theme(t){var r=document.documentElement;r.classList.remove("dark","light");if(t==="dark"||t==="light")r.classList.add(t)}99+function size(){send({method:"ui/notifications/size-changed",params:{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight}})}100+function show(res){101+ var v=res&&res._meta&&res._meta["o12/view"];if(!v)return;102+ document.getElementById("o12-view").innerHTML=v.html;103+ var c=document.getElementById("o12-context");if(c)c.textContent=v.where;104+ var first=document.querySelector("#o12-view td[data-a]");if(first&&window.o12point)window.o12point(first);105+ size();106+}107+window.addEventListener("message",function(e){108+ var m=e.data;if(!m||m.jsonrpc!=="2.0")return;109+ if(m.id===1&&m.result){var hc=m.result.hostContext||{};theme(hc.theme);send({method:"ui/notifications/initialized",params:{}});return}110+ switch(m.method){111+ case "ui/notifications/tool-result":show(m.params);break;112+ case "ui/notifications/host-context-changed":if(m.params)theme(m.params.theme);break;113+ case "ui/resource-teardown":send({id:m.id,result:{}});break;114+ }115+});116+send({id:next++,method:"ui/initialize",params:{protocolVersion:"2026-01-26",capabilities:{},clientInfo:{name:"012",version:"1"},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}});117+new ResizeObserver(size).observe(document.body);118+})();119+`@@ -0,0 +1,74 @@
1+package mcp2+3+import (4+ "context"5+ "strings"6+ "testing"7+8+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"9+)10+11+// connectApps is connect with a client that declares the MCP Apps12+// extension.13+func connectApps(t *testing.T, b Backend) *sdk.ClientSession {14+ t.Helper()15+ ctx := context.Background()16+ st, ct := sdk.NewInMemoryTransports()17+ ss, err := New(b, Options{}).Connect(ctx, st, nil)18+ if err != nil {19+ t.Fatal(err)20+ }21+ t.Cleanup(func() { ss.Close() })22+ caps := &sdk.ClientCapabilities{}23+ caps.AddExtension(uiExtension, map[string]any{"mimeTypes": []string{uiMIME}})24+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}).Connect(ctx, ct, nil)25+ if err != nil {26+ t.Fatal(err)27+ }28+ t.Cleanup(func() { cs.Close() })29+ return cs30+}31+32+func viewHTML(res *sdk.CallToolResult) string {33+ v, _ := res.Meta[viewKey].(map[string]any)34+ html, _ := v["html"].(string)35+ return html36+}37+38+func TestViews(t *testing.T) {39+ path := bookFile(t)40+ cs := connectApps(t, &FileBackend{Path: path})41+ ctx := context.Background()42+ for tl, err := range cs.Tools(ctx, nil) {43+ if err != nil {44+ t.Fatal(err)45+ }46+ ui, _ := tl.Meta["ui"].(map[string]any)47+ drawn := tl.Name == "read_range" || tl.Name == "create_chart"48+ if (ui["resourceUri"] == viewURI) != drawn {49+ t.Errorf("%s: _meta %v", tl.Name, tl.Meta)50+ }51+ }52+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: viewURI})53+ if err != nil {54+ t.Fatal(err)55+ }56+ page := res.Contents[0]57+ if page.MIMEType != uiMIME || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "@font-face") ||58+ !strings.Contains(page.Text, `<div id="o12-view"></div>`) {59+ t.Errorf("view resource: %s %.200s", page.MIMEType, page.Text)60+ }61+ got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)62+ if html := viewHTML(got); !strings.Contains(html, `<table class="grid">`) || !strings.Contains(html, `data-a="A2"`) {63+ t.Errorf("read_range view: %.300s", html)64+ }65+ got = call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)66+ if html := viewHTML(got); !strings.Contains(html, `<figure class="chart solo"`) || !strings.Contains(html, "<svg") {67+ t.Errorf("create_chart view: %.300s", html)68+ }69+ // A client without the extension gets no view.70+ plain := connect(t, &FileBackend{Path: path}, Options{})71+ if got := call(t, plain, "read_range", map[string]any{"ref": "A1"}, nil); got.Meta[viewKey] != nil {72+ t.Error("a view for a client without the extension")73+ }74+}@@ -0,0 +1,169 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/headless"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The tools that change the workbook. Each is one call to the backend's13+// Change, so one undo step and one save, and returns what changed as14+// 012 diff lists it; dry_run returns it without keeping it.15+16+type writeIn struct {17+ 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"`18+ DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`19+}20+21+type opsIn struct {22+ Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`23+ DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`24+}25+26+type sortIn struct {27+ Ref string `json:"ref" jsonschema:"the range to sort"`28+ Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`29+ Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`30+ DryRun bool `json:"dry_run,omitempty"`31+}32+33+type filterIn struct {34+ Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`35+ Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`36+ Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`37+ DryRun bool `json:"dry_run,omitempty"`38+}39+40+type chartIn struct {41+ headless.ChartSpec42+ DryRun bool `json:"dry_run,omitempty"`43+}44+45+type pivotIn struct {46+ headless.PivotSpec47+ DryRun bool `json:"dry_run,omitempty"`48+}49+50+type runCellIn struct {51+ Notebook string `json:"notebook" jsonschema:"the notebook tab's name"`52+ Cell int `json:"cell" jsonschema:"the cell's number, 1 for the first, as describe lists them"`53+}54+55+// Changed is what a write returns: whether it was kept, what changed56+// as 012 diff lists it, and warnings of entries a rule marks invalid.57+type Changed struct {58+ Saved bool `json:"saved"`59+ Changes []headless.Change `json:"changes"`60+ Warnings []string `json:"warnings"`61+}62+63+type chartOut struct {64+ Chart headless.ChartMade `json:"chart"`65+ Changed66+}67+68+type pivotOut struct {69+ Sheet string `json:"sheet"`70+ Changed71+}72+73+var writes = &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(false)}74+75+func (s *Server) addWriteTools() {76+ set := headless.SetOptions{Force: s.opts.Force}77+ tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,78+ 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."},79+ func(ctx context.Context, _ *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {80+ var warnings []string81+ return s.change(ctx, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {82+ warnings, err = headless.Set(w, in.Entries, set)83+ return err84+ })85+ })86+ tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},87+ 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."},88+ func(ctx context.Context, _ *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {89+ var warnings []string90+ return s.change(ctx, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {91+ warnings, err = headless.Apply(w, in.Operations, set)92+ return err93+ })94+ })95+ tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},96+ func(ctx context.Context, _ *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {97+ return s.change(ctx, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })98+ })99+ tool(s, &sdk.Tool{Name: "filter", Annotations: writes,100+ Description: "Put a filter on a range, hiding the rows its columns' conditions or values leave out, as Data > Create a filter does; or remove the sheet's filter."},101+ func(ctx context.Context, _ *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {102+ return s.change(ctx, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })103+ })104+ tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: s.viewMeta(),105+ Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},106+ s.createChart)107+ tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,108+ Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},109+ func(ctx context.Context, _ *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {110+ var out pivotOut111+ _, changed, err := s.change(ctx, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {112+ out.Sheet, err = headless.AddPivot(w, in.PivotSpec)113+ return err114+ })115+ out.Changed = changed116+ return nil, out, err117+ })118+ if s.opts.Notebooks != nil {119+ tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},120+ Description: "Run a notebook tab's code cell with nushell, as Run does on the screen: its output replaces the one kept and goes on to the sheet it was sent to."},121+ s.runCell)122+ }123+}124+125+func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {126+ var out chartOut127+ var res *sdk.CallToolResult128+ _, changed, err := s.change(ctx, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {129+ if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {130+ return err131+ }132+ res = s.chartView(req, w, out.Chart)133+ return nil134+ })135+ out.Changed = changed136+ return res, out, err137+}138+139+func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {140+ var out headless.CellRun141+ _, _, err := s.change(ctx, "run notebook cell", false, nil, func(w *sheet.Workbook) error {142+ if s.opts.MayRun != nil {143+ if err := s.opts.MayRun(w); err != nil {144+ return err145+ }146+ }147+ var err error148+ out, err = headless.RunCell(ctx, w, in.Notebook, in.Cell, *s.opts.Notebooks)149+ return err150+ })151+ return nil, out, err152+}153+154+// change makes fn a change of the backend's and says what it changed;155+// warnings, when set, are fn's.156+func (s *Server) change(ctx context.Context, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {157+ changes, err := s.b.Change(ctx, label, dryRun, fn)158+ if err != nil {159+ return nil, Changed{}, err160+ }161+ out := Changed{Saved: !dryRun && len(changes) > 0, Changes: headless.Changes(changes), Warnings: []string{}}162+ if warnings != nil && *warnings != nil {163+ out.Warnings = *warnings164+ }165+ if out.Saved {166+ s.refreshResources(ctx)167+ }168+ return nil, out, nil169+}@@ -115,7 +115,7 @@ func (m *Model) writeTo(name string, force bool) tea.Cmd {
115115 }116116 return m.runCommand("file.save")117117 }118- k, ok := fileio.KindOf(name)118+ k, ok := fileio.ExportKindOf(name)119119 switch {120120 case !ok && force:121121 return m.saveAs(withExt(name), false)@@ -9,11 +9,16 @@ require (
99 1010 require (1111 github.com/andybalholm/brotli v1.1.1 // indirect12+ github.com/charmbracelet/x/ansi v0.11.8 // indirect13+ github.com/clipperhouse/displaywidth v0.11.0 // indirect14+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect1215 github.com/cockroachdb/apd/v3 v3.2.3 // indirect1316 github.com/dustin/go-humanize v1.0.1 // indirect1417 github.com/google/uuid v1.6.0 // indirect1518 github.com/klauspost/compress v1.17.9 // indirect19+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect1620 github.com/mattn/go-isatty v0.0.24 // indirect21+ github.com/mattn/go-runewidth v0.0.30 // indirect1722 github.com/ncruces/go-strftime v1.0.0 // indirect1823 github.com/parquet-go/bitpack v1.0.0 // indirect1924 github.com/parquet-go/jsonlite v1.0.0 // indirect@@ -6,6 +6,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
66 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=77 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=88 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=9+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=10+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=11+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=12+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=13+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=14+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=915 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=1016 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=1117 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=@@ -26,8 +32,12 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
2632 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=2733 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=2834 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=35+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=36+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=2937 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=3038 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=39+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=40+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=3141 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=3242 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=3343 github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=