1package sheet23import (4 "bytes"5 "cmp"6 "errors"7 "slices"89 "src.rest/cat/012/internal/formula"10)1112// Proposals: a change made on a copy of a workbook, to be made on the13// workbook itself later, whole or cell by cell, or not at all. An14// agent's suggestion in live mode is one (docs/agents/live.md): the15// person sees the cells it would change and accepts some or all of16// them, and what's accepted is one step, its author the workbook's17// author when it's applied, so undo and the others' marks follow as18// for any step.19//20// A change that does more than set cells (inserting rows, adding a21// sheet or a chart, a filter) is made whole only, by running it again22// on the workbook; a change of cells alone is applied as the cells it23// made, so what's accepted is what was shown.2425// Proposal is a change made on a copy of a workbook.26type Proposal struct {27 Label string28 // Cells are the cells whose contents, format or note the change29 // sets, by sheet in the copy's order, then row and column.30 Cells []Proposed31 // Other says what the change does besides setting cells, one32 // phrase each ("inserts or deletes rows or columns on Sheet1"); a33 // proposal with any is made whole only.34 Other []string35 // Sheets are the sheets, by name, whose lines, charts, rules,36 // filters or pivot the change changes; Book is set when it changes37 // the sheet list, the settings, the names or the macros.38 Sheets []string39 Book bool40 // Remote is set when a formula it enters asks a hosted model (JEV),41 // which reaches the network once the change is made.42 Remote bool4344 copy *Workbook45 fn func(*Workbook) error46}4748// Proposed is a cell a proposal sets: its sheet, where, its input49// before and after ("" for blank), and whether its format, style or50// note change too.51type Proposed struct {52 Sheet string53 At Addr54 Was, Now string55 Look bool56}5758// ErrWhole is returned when part of a proposal that must be made whole59// is picked.60var ErrWhole = errors.New("this change does more than set cells: it can only be accepted whole")6162// Propose makes fn, labelled label, on a copy of w and says what it63// changed. w isn't changed; fn may be run again on it when the proposal64// is applied whole.65func Propose(w *Workbook, label string, fn func(*Workbook) error) (*Proposal, error) {66 var data bytes.Buffer67 if err := w.Write(&data); err != nil {68 return nil, err69 }70 s, err := Read(&data)71 if err != nil {72 return nil, err73 }74 c := s.Book()75 before := c.StateID()76 if err := c.Batch(Change{Label: label, Sheet: c.Sheet(c.Active())}, func() error { return fn(c) }); err != nil {77 return nil, err78 }79 p := &Proposal{Label: label, copy: c, fn: fn}80 if st := c.hist.top(); st != nil && st.id != before {81 p.collect(w, st)82 }83 return p, nil84}8586// Empty reports whether the proposal changes nothing.87func (p *Proposal) Empty() bool { return len(p.Cells) == 0 && len(p.Other) == 0 }8889// Whole reports whether the proposal can only be made whole.90func (p *Proposal) Whole() bool { return len(p.Other) > 0 }9192// Result is the copy with the change made, to read what it would look93// like. It must not be changed.94func (p *Proposal) Result() *Workbook { return p.copy }9596// collect notes what step st made on the copy, against w.97func (p *Proposal) collect(w *Workbook, st *step) {98 order := map[string]int{}99 for i, s := range p.copy.sheets {100 order[s.name] = i101 }102 for s, img := range st.cells {103 orig := w.Lookup(s.name)104 img.addrs(func(a Addr) bool {105 p.cell(orig, s, a)106 return true107 })108 }109 slices.SortFunc(p.Cells, func(x, y Proposed) int {110 return cmp.Or(cmp.Compare(order[x.Sheet], order[y.Sheet]), cmp.Compare(x.At.Row, y.At.Row), cmp.Compare(x.At.Col, y.At.Col))111 })112 p.others(st)113}114115// cell notes a's change on the copy's sheet s, when it changed from116// what the workbook's sheet orig holds (nil: the sheet is new).117func (p *Proposal) cell(orig, s *Sheet, a Addr) {118 now := s.cells.get(a)119 if now.Spilled() || now != nil && now.derived {120 return // an array's or a pivot's: its formula is the change121 }122 var was *Cell123 if orig != nil {124 was = orig.cells.get(a)125 }126 in := func(c *Cell) string {127 if c == nil {128 return ""129 }130 return c.Input131 }132 look := func(c *Cell) (Format, Style, string) {133 if c == nil {134 return Format{}, Style{}, ""135 }136 return c.Format, c.Style, c.Note137 }138 wf, ws, wn := look(was)139 nf, ns, nn := look(now)140 lookChanged := wf != nf || ws != ns || wn != nn141 if in(was) == in(now) && !lookChanged {142 return143 }144 p.Cells = append(p.Cells, Proposed{Sheet: s.name, At: a, Was: in(was), Now: in(now), Look: lookChanged})145 if now != nil && now.expr != nil && callsRemote(now.expr) {146 p.Remote = true147 }148}149150// others notes what the step changed besides cells.151func (p *Proposal) others(st *step) {152 sheets := map[*Sheet]bool{}153 add := func(s *Sheet) {154 if !sheets[s] {155 sheets[s] = true156 p.Sheets = append(p.Sheets, s.name)157 }158 }159 for s := range st.shifts {160 add(s)161 p.Other = append(p.Other, "inserts or deletes rows or columns on "+s.name)162 }163 for _, m := range []struct {164 what string165 on map[*Sheet]bool166 }{{"charts", keysOf(st.charts)}, {"a pivot table", keysOf(st.pivots)}, {"rules", keysOf(st.rules)}, {"regions", keysOf(st.regions)}, {"the view", keysOf(st.views)}} {167 for s := range m.on {168 add(s)169 p.Other = append(p.Other, "changes "+m.what+" on "+s.name)170 }171 }172 lines := map[*Sheet]bool{}173 for k := range st.widths {174 lines[k.s] = true175 }176 for k := range st.heights {177 lines[k.s] = true178 }179 for k := range st.lines {180 lines[k.s] = true181 }182 for s := range lines {183 add(s)184 p.Other = append(p.Other, "changes columns' or rows' sizes or formats on "+s.name)185 }186 for _, b := range []struct {187 what string188 on bool189 }{{"adds, deletes, renames or moves sheets", st.sheets != nil}, {"changes named ranges", len(st.names) > 0},190 {"changes the settings", st.settings != nil}, {"changes the macros", st.macros != nil}} {191 if b.on {192 p.Book = true193 p.Other = append(p.Other, b.what)194 }195 }196 slices.Sort(p.Other)197}198199// callsRemote reports whether a formula calls a function a hosted200// model answers.201func callsRemote(n Node) bool {202 found := false203 var walk func(Node)204 walk = func(n Node) {205 if call, ok := n.(formula.Call); ok && !found {206 if f := funcOf(call); f != nil && f.Remote() {207 found = true208 }209 }210 formula.EachChild(n, walk)211 }212 walk(n)213 return found214}215216// Apply makes the proposal on w as one step, in the name of w's author:217// the cells pick keeps (nil keeps them all), or the whole change run218// again on w when it does more than set cells.219func (p *Proposal) Apply(w *Workbook, pick func(i int) bool) error {220 if p.Whole() {221 for i := range p.Cells {222 if pick != nil && !pick(i) {223 return ErrWhole224 }225 }226 return w.Batch(Change{Label: p.Label, Sheet: w.Sheet(w.Active())}, func() error { return p.fn(w) })227 }228 var picked []Proposed229 for i, c := range p.Cells {230 if pick == nil || pick(i) {231 picked = append(picked, c)232 }233 }234 if len(picked) == 0 {235 return nil236 }237 first := w.Lookup(picked[0].Sheet)238 if first == nil {239 return errors.New(picked[0].Sheet + " is gone")240 }241 focus := Rect{From: picked[0].At, To: picked[0].At}242 for _, c := range picked[1:] {243 if c.Sheet == picked[0].Sheet {244 focus = union(focus, Rect{From: c.At, To: c.At})245 }246 }247 return w.Batch(Change{Label: p.Label, Sheet: first, Focus: focus}, func() error {248 for _, c := range picked {249 if err := p.place(w, c); err != nil {250 return err251 }252 }253 return nil254 })255}256257// place sets c's cell on w as the copy holds it.258func (p *Proposal) place(w *Workbook, c Proposed) error {259 s := w.Lookup(c.Sheet)260 if s == nil {261 return errors.New(c.Sheet + " is gone")262 }263 r := Rect{From: c.At, To: c.At}264 if s.InPivot(r) {265 return ErrPivotEdit266 }267 if _, ok := s.SpillAnchor(c.At); ok && c.Now != "" {268 return ErrSpillEdit269 }270 src := p.copy.Lookup(c.Sheet).cells.get(c.At)271 if src == nil {272 if s.cells.has(c.At) {273 s.place(c.At, nil)274 }275 return nil276 }277 s.place(c.At, src.clone())278 return nil279}