cat / 012
clone

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

cat/module-path
012 / internal / mcp / write.go
write.go 10.4K
1package mcp23import (4 "context"56 sdk "github.com/modelcontextprotocol/go-sdk/mcp"78 "src.rest/cat/012/internal/headless"9 "src.rest/cat/012/internal/sheet"10)1112// The tools that change a 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.1516// whyIn is a change's message, in live mode.17type whyIn struct {18 Message string `json:"message,omitempty" jsonschema:"live mode: why, shown to the person with your suggestion"`19}2021type writeIn struct {22 bookIn23 whyIn24 Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input as a person types it, or its value with its type, and a format; an empty input or a null value clears the cell"`25 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`26}2728type tableIn struct {29 bookIn30 whyIn31 headless.TableSpec32 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the rows and return the change without making it"`33}3435type opsIn struct {36 bookIn37 whyIn38 Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`39 DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`40}4142type sortIn struct {43 bookIn44 whyIn45 Ref string `json:"ref" jsonschema:"the range to sort"`46 Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`47 Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`48 DryRun bool `json:"dry_run,omitempty"`49}5051type filterIn struct {52 bookIn53 whyIn54 Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`55 Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`56 Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`57 DryRun bool `json:"dry_run,omitempty"`58}5960type chartIn struct {61 bookIn62 whyIn63 headless.ChartSpec64 DryRun bool `json:"dry_run,omitempty"`65}6667type pivotIn struct {68 bookIn69 whyIn70 headless.PivotSpec71 DryRun bool `json:"dry_run,omitempty"`72}7374type runCellIn struct {75 bookIn76 Notebook string `json:"notebook" jsonschema:"the notebook tab's name"`77 Cell int `json:"cell" jsonschema:"the cell's number, 1 for the first, as describe lists them"`78}7980// Changed is what a write returns: whether it was kept, what changed81// as 012 diff lists it, and warnings of entries a rule marks invalid.82type Changed struct {83 Saved bool `json:"saved"`84 Changes []headless.Change `json:"changes"`85 Warnings []string `json:"warnings"`86 // Suggestion and Notices are live mode's: the change waiting for87 // the person, and what became of earlier ones.88 Suggestion int `json:"suggestion,omitempty" jsonschema:"live mode: the suggestion's number, waiting for the person to accept or reject it"`89 Notices []string `json:"notices,omitempty" jsonschema:"live mode: what became of your earlier suggestions"`90}9192type chartOut struct {93 Chart headless.ChartMade `json:"chart"`94 Changed95}9697type pivotOut struct {98 Sheet string `json:"sheet"`99 Changed100}101102var writes = &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(false)}103104func (s *Server) addWriteTools() {105 set := headless.SetOptions{Force: s.opts.Force}106 tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,107 Description: "Set cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take. " +108 "Write money, percentages, dates, times, durations and sizes with their types, never as bare numbers: an input as a person types it ($3.50, 12%, 2026-09-29), or a value ({\"currency\": 3.5}, {\"percent\": 0.12}, {\"date\": \"2026-09-29\"}, {\"duration\": \"90min\"}, {\"size\": \"1.5kb\"}); \"00123\" as a value stays text. " +109 "format sets a number format code on the cell, or alone on a range. For rows of records, write_table."},110 func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {111 var warnings []string112 return s.change(ctx, req, in.Path, "set", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {113 warnings, err = headless.Set(w, in.Entries, set)114 return err115 })116 })117 tool(s, &sdk.Tool{Name: "write_table", Annotations: writes,118 Description: "Write a table as one change: a header row at `at` naming the records' fields, then a row for each record, its values typed as write_cells takes them, " +119 "each column formatted as its first typed value is (or as formats says), as importing a NUON table does. " +120 "Example rows: [{\"Item\": \"Tea\", \"Price\": {\"currency\": 3.5}, \"Share\": {\"percent\": 0.12}, \"Bought\": {\"date\": \"2026-09-29\"}, \"Code\": \"00123\"}]."},121 func(ctx context.Context, req *sdk.CallToolRequest, in tableIn) (*sdk.CallToolResult, Changed, error) {122 var warnings []string123 return s.change(ctx, req, in.Path, "write table", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {124 warnings, err = headless.WriteTable(w, in.TableSpec, set)125 return err126 })127 })128 tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},129 Description: "Make several operations as one change: set (input or typed value, and format, as write_cells), write_table (rows at ref, as write_table), clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},130 func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {131 var warnings []string132 return s.change(ctx, req, in.Path, "apply operations", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {133 warnings, err = headless.Apply(w, in.Operations, set)134 return err135 })136 })137 tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},138 func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {139 return s.change(ctx, req, in.Path, "sort", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })140 })141 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,142 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."},143 func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {144 return s.change(ctx, req, in.Path, "filter", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })145 })146 tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),147 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},148 s.createChart)149 tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,150 Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},151 func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {152 var out pivotOut153 _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {154 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)155 return err156 })157 out.Changed = changed158 return nil, out, err159 })160 if s.opts.Live != nil {161 return // the live tools run notebook cells; there are no files to create162 }163 s.addCreateTool()164 if s.opts.Notebooks != nil {165 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},166 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."},167 s.runCell)168 }169}170171func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {172 var out chartOut173 var view *View174 b, err := s.open(ctx, req, in.Path)175 if err != nil {176 return nil, out, err177 }178 _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {179 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {180 return err181 }182 view = chartView(b, w, out.Chart)183 return nil184 })185 if err != nil {186 return nil, chartOut{}, err187 }188 out.Changed = changed189 return viewResult(view), out, nil190}191192func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {193 var out headless.CellRun194 b, err := s.open(ctx, req, in.Path)195 if err != nil {196 return nil, out, err197 }198 _, _, err = s.change(ctx, req, in.Path, "run notebook cell", "", false, nil, func(w *sheet.Workbook) error {199 if s.opts.MayRun != nil {200 if err := s.opts.MayRun(b.path, w); err != nil {201 return err202 }203 }204 var err error205 out, err = headless.RunCell(ctx, w, in.Notebook, in.Cell, *s.opts.Notebooks)206 return err207 })208 return nil, out, err209}210211// change makes fn a change of the workbook path names and says what it212// changed; warnings, when set, are fn's.213func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label, message string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {214 b, err := s.open(ctx, req, path)215 if err != nil {216 return nil, Changed{}, err217 }218 var made Made219 if l, ok := b.Backend.(Live); ok {220 made, err = l.Make(ctx, Making{Label: label, Message: message, DryRun: dryRun, Fn: fn})221 } else {222 made.Changes, err = b.Change(ctx, label, dryRun, fn)223 made.Applied = !dryRun224 }225 if err != nil {226 return nil, Changed{}, err227 }228 out := Changed{Saved: made.Applied && len(made.Changes) > 0, Changes: headless.Changes(made.Changes), Warnings: []string{},229 Suggestion: made.Suggestion, Notices: made.Notices}230 if warnings != nil && *warnings != nil {231 out.Warnings = *warnings232 }233 if out.Saved {234 s.refreshResources(ctx)235 }236 return nil, out, nil237}