cat / 012
clone

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

main
012 / internal / headless / notebook.go
notebook.go 6.8K
1package headless23import (4 "bytes"5 "context"6 "errors"7 "fmt"8 "time"910 "src.rest/cat/012/internal/fileio"11 "src.rest/cat/012/internal/notebook"12 "src.rest/cat/012/internal/nushell"13 "src.rest/cat/012/internal/sheet"14)1516// NotebookOptions say how RunNotebooks runs cells.17type NotebookOptions struct {18 Runner nushell.Runner // nu, or a fake in tests19 Timeout time.Duration // for each cell20 NuConfig bool // run nu with the user's config files21}2223// maxOutput is the most a run may print, as on the screen.24const maxOutput = 512 << 202526// RunNotebooks runs the code cells of each notebook tab, each after the27// cells it reads, as Run all does on the screen: each cell's output28// replaces the one the file kept and goes on to the sheet it was sent29// to. A failure stops its notebook's run, as on the screen. It returns30// a line for each cell that failed or couldn't run.31func RunNotebooks(ctx context.Context, w *sheet.Workbook, o NotebookOptions) []string {32 var failed []string33 for _, s := range w.Sheets() {34 if s.IsNotebook() {35 failed = append(failed, runNotebook(ctx, w, s, o)...)36 }37 }38 return failed39}4041func runNotebook(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, o NotebookOptions) []string {42 cells := s.NotebookCells()43 all := make([]int, len(cells))44 for i := range all {45 all[i] = i46 }47 order, cycle := notebook.Order(cells, all)48 var failed []string49 for _, i := range cycle {50 failed = append(failed, fmt.Sprintf("%s cell %d reads itself, through the cells it reads: it doesn't run", s.Name(), i+1))51 }52 for n, i := range order {53 if err := runCell(ctx, w, cells, i, n+1, o); err != nil {54 return append(failed, fmt.Sprintf("%s cell %d: %s", s.Name(), i+1, err))55 }56 }57 return failed58}5960// runCell runs cell i, the count-th run, keeping its output or why it61// failed.62func runCell(ctx context.Context, w *sheet.Workbook, cells []notebook.Cell, i, count int, o NotebookOptions) error {63 c := cells[i]64 out := &notebook.Output{Count: count, Source: c.Source}65 defer func() {66 w.SetOutput(c.ID, out)67 feedOutput(w, c.Name())68 }()69 if why := notebook.Taken(cells, i); why != "" {70 out.Err = why71 return errors.New(why)72 }73 job, reads, err := jobFor(w, cells, i, o.NuConfig)74 if err != nil {75 out.Err = err.Error()76 return err77 }78 out.Reads = reads79 start := time.Now()80 data, vars, err := nushell.ExecVars(ctx, o.Runner, job, o.Timeout, maxOutput)81 out.Took = time.Since(start)82 if err != nil {83 out.Err = err.Error()84 var nuErr *nushell.Error85 if errors.As(err, &nuErr) {86 out.Err, out.Detail = nuErr.Msg, nuErr.Help()87 }88 return errors.New(out.Err)89 }90 out.NUON, out.Vars = append([]byte{}, data...), vars91 return nil92}9394// jobFor is what running cell i takes: the outputs, variables, linked95// files and ranges it reads, as NUON, and the outputs it read by name.96func jobFor(w *sheet.Workbook, cells []notebook.Cell, i int, config bool) (nushell.Job, map[string]int, error) {97 p, err := notebook.Prepare(cells, i, w.Output)98 job := nushell.Job{Command: p.Command, Vars: p.Exports, Tables: p.Tables, Config: config}99 if err != nil {100 return job, nil, err101 }102 for _, name := range p.Others {103 if t, r, ok := w.Region(name); ok && r.Linked() && r.Name == name {104 data, err := regionNUON(t, r)105 if err != nil {106 return job, nil, err107 }108 job.Tables[name] = data109 }110 }111 if p.Selection {112 return job, nil, errors.New("it reads $selection, which only the screen has")113 }114 for _, ref := range p.Ranges {115 t, rng, err := rangeRef(w, ref.Ref)116 if err != nil {117 return job, nil, err118 }119 data, err := encodeNUON(fileio.Snap(t, rng, t.Name()))120 if err != nil {121 return job, nil, err122 }123 job.Tables[ref.Var] = data124 }125 return job, p.Reads, nil126}127128// regionNUON is a linked file's table as NUON, read whole whatever its129// filter shows, as on the screen.130func regionNUON(t *sheet.Sheet, r sheet.Region) ([]byte, error) {131 rng, ok := t.RegionTable(r.Name)132 if !ok {133 rng = sheet.Rect{From: r.At, To: r.At}134 }135 snap := fileio.Snap(t, rng, r.Name)136 snap.HiddenRows = nil137 if !ok {138 snap.Cells = nil139 }140 return encodeNUON(snap)141}142143// rangeRef is the sheet and range $sheet.ref reads: of the sheet it144// names, or else of the sheet shown when the file was saved, or the145// first sheet that isn't a notebook.146func rangeRef(w *sheet.Workbook, ref string) (*sheet.Sheet, sheet.Rect, error) {147 name, cells := sheet.SplitSheet(ref)148 t := w.Sheet(w.Active())149 if name != "" {150 t = w.Lookup(name)151 } else if t.IsNotebook() {152 for _, s := range w.Sheets() {153 if !s.IsNotebook() {154 t = s155 break156 }157 }158 }159 rng, ok := sheet.ParseRange(cells)160 if t == nil || t.IsNotebook() || !ok {161 return nil, sheet.Rect{}, fmt.Errorf("$sheet.%s isn't a range of a sheet", ref)162 }163 return t, rng, nil164}165166func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {167 var b bytes.Buffer168 _, err := fileio.Encode(&b, fileio.NUON, snap)169 return b.Bytes(), err170}171172// SyncOutputs sends the outputs a file kept to the sheets they were sent173// to, as opening the file on the screen does: reading, not running.174func SyncOutputs(w *sheet.Workbook) {175 for _, name := range w.StaleOutputs() {176 feedOutput(w, name)177 }178}179180// feedOutput sends the output of the cell named name to the region it181// was sent to, if it was: its rows, none when it hasn't run, or why when182// it failed or is gone.183func feedOutput(w *sheet.Workbook, name string) {184 if name == "" {185 return186 }187 _, r, ok := w.Region(name)188 if !ok || !r.Output {189 return190 }191 op := sheet.LiveOp{Region: r.Name, Reset: true}192 c, ok := cellNamed(w, r.Name)193 switch o := w.Output(c.ID); {194 case !ok:195 op = sheet.LiveOp{Region: r.Name, Err: "its cell is gone"}196 case o == nil || o.Unsaved:197 case o.Failed():198 op = sheet.LiveOp{Region: r.Name, Err: o.Err}199 default:200 rows, note, err := fileio.NUONRows(context.Background(), o.NUON, 0)201 if err != nil {202 op = sheet.LiveOp{Region: r.Name, Err: err.Error()}203 break204 }205 resolve := func(ref string) (*sheet.Sheet, sheet.Rect, error) { return rangeRef(w, ref) }206 fileio.KeepFormats(&rows, fileio.OutputFormats(w, o, resolve))207 op.Header, op.Rows, op.Note = rows.Header, rows.Rows, note208 }209 w.ApplyLive(op)210}211212// MissingOutputs names the notebook outputs sent to s that show no rows213// because the file holds no output for their cells: never run, too214// large to save, or their cells gone.215func MissingOutputs(s *sheet.Sheet) []string {216 var out []string217 for _, r := range s.Regions() {218 if !r.Output {219 continue220 }221 c, ok := cellNamed(s.Book(), r.Name)222 if o := s.Book().Output(c.ID); !ok || o == nil || o.Unsaved {223 out = append(out, r.Name)224 }225 }226 return out227}228229// cellNamed is the notebook cell that gives its output name.230func cellNamed(w *sheet.Workbook, name string) (notebook.Cell, bool) {231 for _, s := range w.Sheets() {232 cells := s.NotebookCells()233 if i, ok := notebook.Names(cells)[name]; ok {234 return cells[i], true235 }236 }237 return notebook.Cell{}, false238}