cat / 012
clone

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

Add workbooks: several sheets with references between them

Dami committed cb0c245
A file now holds ordered sheets, each with its own cells, widths,
frozen panes, filter and charts, while named ranges, undo and
recalculation are shared. Formulas read other sheets as Sheet2!A1 or
'My Sheet'!B2:C9; renaming a sheet rewrites them, deleting one leaves
them showing #REF! until a sheet of that name exists again, as in
Sheets. Inserting and deleting rows moves references into that sheet
from anywhere, and cells can move between sheets. The file format
gains version 4 for several sheets; a single sheet still writes
version 2 or 3. XLSX import and export carry every sheet and the
named ranges; other imports name their sheet after the file.
commit
cb0c2452e53827dd1906de4efa403dfe96903ebb
parent
3d3b7b7076af1d7d906447c6a0c95de4c88bce70
author
Dami <dami.etoile@gmail.com>
34 files changed, +2333 -515
Minternal/fileio/builder.go12+-
Minternal/fileio/csv_test.go3+
Minternal/fileio/fileio.go10+
Minternal/fileio/formula.go26++
Minternal/fileio/snapshot.go44+++
Minternal/fileio/xlsx.go252+++++++++++-----
Minternal/fileio/xlsx_test.go77++++-
Minternal/sheet/args.go22+-
Minternal/sheet/cellformat.go6+-
Minternal/sheet/decimal.go26+-
Minternal/sheet/eval.go10+-
Minternal/sheet/explain.go66+++-
Minternal/sheet/file.go266+++++++++++++---
Minternal/sheet/functions.go4+-
Minternal/sheet/functions_jev.go4+-
Minternal/sheet/functions_link.go2+-
Minternal/sheet/history.go326++++++++++++++------
Minternal/sheet/history_test.go4+-
Minternal/sheet/names.go124+++++---
Minternal/sheet/names_test.go16+-
Minternal/sheet/observe.go19+-
Minternal/sheet/ops.go134++++++++-
Minternal/sheet/parse.go65+++-
Minternal/sheet/print.go9+
Minternal/sheet/refs.go26+-
Minternal/sheet/refs_test.go14+-
Minternal/sheet/remote.go9+-
Minternal/sheet/sheet.go245++++++++++-----
Minternal/sheet/sheet_test.go10+-
Minternal/sheet/trace.go18+-
Minternal/sheet/view.go36+-
Ainternal/sheet/workbook.go495++++++++++++++++++++++++++++++
Ainternal/sheet/workbook_test.go464++++++++++++++++++++++++++++
Minternal/ui/panel.go4+-
M internal/fileio/builder.go +7 -5
M internal/fileio/csv_test.go +3 -0
M internal/fileio/fileio.go +10 -0
M internal/fileio/formula.go +26 -0
M internal/fileio/snapshot.go +44 -0
@@ -1,6 +1,8 @@
11 package fileio22 33 import (4+ "strconv"5+46 "github.com/FelineStateMachine/012/internal/sheet"57 )68
@@ -14,6 +16,13 @@ type Snapshot struct {
1416 Cells map[sheet.Addr]SnapCell1517 Widths map[int]int // non-default column widths1618 Name string // what to call the data: a sheet or table name19+20+ // Sheets are every sheet of the workbook, in order, for formats that21+ // hold several (XLSX); the snapshot itself is one of them, the one22+ // shown. Nil exports just this snapshot. Names are the workbook's23+ // named ranges as name and Excel reference, e.g. Q3!$B$2:$B$9.24+ Sheets []*Snapshot25+ Names [][2]string1726 }1827 1928 // SnapCell is one non-blank cell of a snapshot.
@@ -55,6 +64,41 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
5564 return snap5665 }5766 67+// SnapBook copies every sheet of s's workbook, whole, with the named68+// ranges, for formats that hold several sheets. The result is s's69+// snapshot.70+func SnapBook(s *sheet.Sheet) *Snapshot {71+ var out *Snapshot72+ all := []*Snapshot{}73+ for _, t := range s.Book().Sheets() {74+ sn := Snap(t, sheet.Rect{}, t.Name())75+ if t == s {76+ out = sn77+ }78+ all = append(all, sn)79+ }80+ out.Sheets = all81+ for _, n := range s.Book().Names() {82+ if !n.Gone() {83+ out.Names = append(out.Names, [2]string{n.Name, excelRange(n.Sheet.Name(), n.Range)})84+ }85+ }86+ return out87+}88+89+// excelRange writes a range as Excel's defined names hold it: absolute,90+// with its sheet, Q3!$B$2:$B$9.91+func excelRange(ws string, r sheet.Rect) string {92+ abs := func(a sheet.Addr) string {93+ return "$" + sheet.ColName(a.Col) + "$" + strconv.Itoa(a.Row+1)94+ }95+ ref := abs(r.From)96+ if r.From != r.To {97+ ref += ":" + abs(r.To)98+ }99+ return sheet.QuoteSheet(sheetName(ws)) + "!" + ref100+}101+58102 // rows returns the snapshot's displayed text row by row across its59103 // range, for text formats.60104 func (s *Snapshot) rows() [][]string {
M internal/fileio/xlsx.go +176 -76
@@ -19,50 +19,85 @@ const (
1919 excelPadding = 12020 )2121 22+// importXLSX reads every sheet of a workbook, with its named ranges, and23+// returns the sheet that was active in Excel.2224 func importXLSX(ctx context.Context, name string, prog *Progress) (*Result, error) {2325 x, err := excelize.OpenFile(name)2426 if err != nil {2527 return nil, err2628 }2729 defer x.Close()28- sheets := x.GetSheetList()29- if len(sheets) == 0 {30+ names := x.GetSheetList()31+ if len(names) == 0 {3032 return nil, fmt.Errorf("the workbook has no sheets")3133 }32- ws := sheets[0]33- var notes []string34- if n := len(sheets) - 1; n > 0 {35- notes = append(notes, fmt.Sprintf("first sheet only, %s; not imported: %s", ws, strings.Join(sheets[1:], ", ")))36- }37-34+ // Rows across all sheets, for the progress bar.35+ dims := make([]int, len(names))3836 total := 039- if dim, err := x.GetSheetDimension(ws); err == nil {40- if _, to, ok := strings.Cut(dim, ":"); ok {41- if _, r, err := excelize.CellNameToCoordinates(to); err == nil {42- total = r37+ for i, ws := range names {38+ if dim, err := x.GetSheetDimension(ws); err == nil {39+ if _, to, ok := strings.Cut(dim, ":"); ok {40+ if _, r, err := excelize.CellNameToCoordinates(to); err == nil {41+ dims[i] = r42+ }4343 }4444 }45+ total += dims[i]4546 }4647 4748 b := newBuilder()49+ book := b.s.Book()4850 styles := map[int]xlsxStyle{}51+ var notes []string52+ done := 053+ for i, ws := range names {54+ if i == 0 {55+ err = book.RenameSheet(b.s, ws)56+ } else {57+ b.s, err = book.AddSheet(ws, i)58+ }59+ if err != nil {60+ return nil, fmt.Errorf("sheet %s: %w", ws, err)61+ }62+ rows, err := importXLSXSheet(ctx, x, b, ws, styles, func(row int) {63+ prog.setRows(done + row)64+ prog.setFrac(int64(done+row), int64(total))65+ })66+ if err != nil {67+ return nil, err68+ }69+ done += rows70+ }71+ notes = append(notes, importXLSXNames(x, book)...)72+ active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))73+ book.SetActive(active)74+ b.s = active75+ prog.setRows(done)76+ s, notes := b.finish(notes)77+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil78+}79+80+func clamp(v, lo, hi int) int { return max(lo, min(v, hi)) }81+82+// importXLSXSheet reads one sheet into b.s, reporting rows read, and83+// returns how many rows it read.84+func importXLSXSheet(ctx context.Context, x *excelize.File, b *builder, ws string, styles map[int]xlsxStyle, report func(int)) (int, error) {4985 rows, err := x.Rows(ws)5086 if err != nil {51- return nil, err87+ return 0, err5288 }5389 defer rows.Close()5490 row := 05591 for ; rows.Next(); row++ {5692 if row%128 == 0 {5793 if err := ctx.Err(); err != nil {58- return nil, err94+ return 0, err5995 }60- prog.setRows(row)61- prog.setFrac(int64(row), int64(total))96+ report(row)6297 }6398 cols, err := rows.Columns(excelize.Options{RawCellValue: true})6499 if err != nil {65- return nil, err100+ return 0, err66101 }67102 for col, raw := range cols {68103 a := sheet.Addr{Col: col, Row: row}
@@ -74,10 +109,8 @@ func importXLSX(ctx context.Context, name string, prog *Progress) (*Result, erro
74109 }75110 }76111 if err := rows.Error(); err != nil {77- return nil, err112+ return 0, err78113 }79- prog.setRows(row)80-81114 for c := range sheet.MaxCols {82115 colName, _ := excelize.ColumnNumberToName(c + 1)83116 w, err := x.GetColWidth(ws, colName)
@@ -86,8 +119,34 @@ func importXLSX(ctx context.Context, name string, prog *Progress) (*Result, erro
86119 }87120 b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))88121 }89- s, notes := b.finish(notes)90- return &Result{Sheet: s, Rows: row, Notes: notes}, nil122+ return row, nil123+}124+125+// importXLSXNames defines the workbook's named ranges that are a range on126+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet127+// scoped names, constants, formulas).128+func importXLSXNames(x *excelize.File, book *sheet.Workbook) []string {129+ skipped := 0130+ example := ""131+ for _, dn := range x.GetDefinedName() {132+ if strings.HasPrefix(dn.Name, "_xlnm.") {133+ continue // print areas and the like134+ }135+ ref := strings.TrimPrefix(dn.RefersTo, "=")136+ ws, rest := sheet.SplitSheet(ref)137+ r, ok := sheet.ParseRange(rest)138+ s := book.Lookup(ws)139+ if dn.Scope != "" && dn.Scope != "Workbook" || !ok || s == nil || book.DefineName(dn.Name, s, r) != nil {140+ skipped++141+ if example == "" {142+ example = dn.Name143+ }144+ }145+ }146+ if skipped > 0 {147+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}148+ }149+ return nil91150 }92151 93152 // xlsxStyle is what 012 keeps of an Excel cell style.
@@ -158,52 +217,103 @@ func importXLSXCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr,
158217 keep()159218 }160219 161-// exportXLSX writes a workbook with one sheet: values, formulas in162-// Excel's syntax with their results cached, number formats, text styles,163-// alignment and column widths.220+// exportXLSX writes a workbook: the snapshot's sheets (or just the221+// snapshot), each with values, formulas in Excel's syntax with their222+// results cached, number formats, text styles, alignment and column223+// widths, and the named ranges.164224 func exportXLSX(name string, snap *Snapshot) (*ExportResult, error) {165225 x := excelize.NewFile()166226 defer x.Close()167- ws := sheetName(snap.Name)168- if err := x.SetSheetName("Sheet1", ws); err != nil {169- return nil, err170- }171- sw, err := x.NewStreamWriter(ws)172- if err != nil {173- return nil, err227+ sheets := snap.Sheets228+ if len(sheets) == 0 {229+ sheets = []*Snapshot{snap}174230 }175- for c, w := range snap.Widths {176- if err := sw.SetColWidth(c+1, c+1, float64(max(w-excelPadding, 1))); err != nil {231+ w := &xlsxWriter{x: x, styleIDs: map[xlsxStyle]int{}, multi: len(sheets) > 1}232+ res := &ExportResult{}233+ for i, sn := range sheets {234+ ws := sheetName(sn.Name)235+ var err error236+ if i == 0 {237+ err = x.SetSheetName("Sheet1", ws)238+ } else {239+ _, err = x.NewSheet(ws)240+ }241+ if err != nil {177242 return nil, err178243 }179- }180- styleIDs := map[xlsxStyle]int{}181- styleFor := func(f sheet.Format, st sheet.Style) (int, error) {182- key := xlsxStyle{f, st}183- if id, ok := styleIDs[key]; ok {184- return id, nil244+ rows, err := w.sheet(ws, sn)245+ if err != nil {246+ return nil, err185247 }186- xs := &excelize.Style{}187- if code := excelCode(f); code != "" {188- xs.CustomNumFmt = &code248+ res.Rows += rows249+ if sn == snap || snap.Sheets == nil {250+ x.SetActiveSheet(i)189251 }190- if st.Bold || st.Italic || st.Underline || st.Strikethrough {191- xs.Font = &excelize.Font{Bold: st.Bold, Italic: st.Italic, Strike: st.Strikethrough}192- if st.Underline {193- xs.Font.Underline = "single"194- }252+ }253+ for _, n := range snap.Names {254+ if err := x.SetDefinedName(&excelize.DefinedName{Name: n[0], RefersTo: n[1]}); err != nil {255+ return nil, err195256 }196- if st.Align != sheet.AlignAuto {197- xs.Alignment = &excelize.Alignment{Horizontal: st.Align.String()}257+ }258+ yes := true259+ if err := x.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: &yes}); err != nil {260+ return nil, err261+ }262+ buf, err := x.WriteToBuffer()263+ if err != nil {264+ return nil, err265+ }266+ if w.values > 0 {267+ res.Notes = append(res.Notes, fmt.Sprintf("%s with no Excel equivalent saved as values, e.g. %s",268+ count(w.values, "formula", "formulas"), w.example))269+ }270+ return res, writeFile(name, buf.Bytes())271+}272+273+// xlsxWriter writes sheets into a workbook, sharing cell styles.274+type xlsxWriter struct {275+ x *excelize.File276+ styleIDs map[xlsxStyle]int277+ values int // formulas written as values278+ example string // the first of them279+ multi bool // examples name their sheet280+}281+282+func (w *xlsxWriter) styleFor(f sheet.Format, st sheet.Style) (int, error) {283+ key := xlsxStyle{f, st}284+ if id, ok := w.styleIDs[key]; ok {285+ return id, nil286+ }287+ xs := &excelize.Style{}288+ if code := excelCode(f); code != "" {289+ xs.CustomNumFmt = &code290+ }291+ if st.Bold || st.Italic || st.Underline || st.Strikethrough {292+ xs.Font = &excelize.Font{Bold: st.Bold, Italic: st.Italic, Strike: st.Strikethrough}293+ if st.Underline {294+ xs.Font.Underline = "single"198295 }199- id, err := x.NewStyle(xs)200- styleIDs[key] = id201- return id, err202296 }297+ if st.Align != sheet.AlignAuto {298+ xs.Alignment = &excelize.Alignment{Horizontal: st.Align.String()}299+ }300+ id, err := w.x.NewStyle(xs)301+ w.styleIDs[key] = id302+ return id, err303+}203304 305+// sheet writes a snapshot to sheet ws and returns the rows written.306+func (w *xlsxWriter) sheet(ws string, snap *Snapshot) (int, error) {307+ sw, err := w.x.NewStreamWriter(ws)308+ if err != nil {309+ return 0, err310+ }311+ for c, width := range snap.Widths {312+ if err := sw.SetColWidth(c+1, c+1, float64(max(width-excelPadding, 1))); err != nil {313+ return 0, err314+ }315+ }204316 r := snap.Range205- values := 0206- example := ""207317 for row := r.From.Row; row <= r.To.Row; row++ {208318 var line []any209319 for col := r.From.Col; col <= r.To.Col; col++ {
@@ -218,8 +328,8 @@ func exportXLSX(name string, snap *Snapshot) (*ExportResult, error) {
218328 f := c.Format219329 id := 0220330 if !f.IsZero() || !c.Style.IsZero() {221- if id, err = styleFor(f, c.Style); err != nil {222- return nil, err331+ if id, err = w.styleFor(f, c.Style); err != nil {332+ return 0, err223333 }224334 }225335 xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, f)}
@@ -227,9 +337,12 @@ func exportXLSX(name string, snap *Snapshot) (*ExportResult, error) {
227337 if fx, ok := toExcelFormula(c.Input); ok {228338 xc.Formula = fx229339 } else {230- values++231- if example == "" {232- example = a.String()340+ w.values++341+ if w.example == "" {342+ w.example = a.String()343+ if w.multi {344+ w.example = sheet.QuoteSheet(ws) + "!" + a.String()345+ }233346 }234347 }235348 }
@@ -237,26 +350,13 @@ func exportXLSX(name string, snap *Snapshot) (*ExportResult, error) {
237350 }238351 cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)239352 if err := sw.SetRow(cell, line); err != nil {240- return nil, err353+ return 0, err241354 }242355 }243356 if err := sw.Flush(); err != nil {244- return nil, err245- }246- yes := true247- if err := x.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: &yes}); err != nil {248- return nil, err249- }250- buf, err := x.WriteToBuffer()251- if err != nil {252- return nil, err357+ return 0, err253358 }254- res := &ExportResult{Rows: r.To.Row - r.From.Row + 1}255- if values > 0 {256- res.Notes = append(res.Notes, fmt.Sprintf("%s with no Excel equivalent saved as values, e.g. %s",257- count(values, "formula", "formulas"), example))258- }259- return res, writeFile(name, buf.Bytes())359+ return r.To.Row - r.From.Row + 1, nil260360 }261361 262362 // xlsxValue is a computed value as excelize writes it.
M internal/fileio/xlsx_test.go +70 -7
@@ -98,8 +98,8 @@ func TestXLSXRoundTrip(t *testing.T) {
9898 }9999 100100 // TestXLSXImport reads a workbook written by excelize the way Excel101-// writes one: several sheets, shared strings, built-in formats and102-// formulas 012 can't read.101+// writes one: several sheets referring to each other, shared strings,102+// built-in formats, named ranges and formulas 012 can't read.103103 func TestXLSXImport(t *testing.T) {104104 x := excelize.NewFile()105105 x.SetSheetName("Sheet1", "Q1")
@@ -114,7 +114,16 @@ func TestXLSXImport(t *testing.T) {
114114 x.SetCellValue("Q1", "A2", 84) // the cached value115115 x.SetCellFormula("Q1", "A2", "Q2!A1*2")116116 x.SetCellFormula("Q1", "B2", "SUM(B1,1)")117+ x.SetCellFormula("Q1", "C2", "SUM('Q3 plan'!A1:A2)+Total")118+ x.SetCellValue("Q1", "D2", 7)119+ x.SetCellFormula("Q1", "D2", "CUBEVALUE(1)")117120 x.SetCellValue("Q2", "A1", 42)121+ x.SetSheetName("Q3", "Q3 plan")122+ x.SetCellValue("Q3 plan", "A1", 1)123+ x.SetCellValue("Q3 plan", "A2", 2)124+ x.SetDefinedName(&excelize.DefinedName{Name: "Total", RefersTo: "Q2!$A$1"})125+ x.SetDefinedName(&excelize.DefinedName{Name: "Local", RefersTo: "Q2!$A$1", Scope: "Q2"})126+ x.SetActiveSheet(1)118127 id, _ := x.NewStyle(&excelize.Style{NumFmt: 4, Font: &excelize.Font{Bold: true}})119128 x.SetCellStyle("Q1", "B1", "B1", id)120129 date, _ := x.NewStyle(&excelize.Style{NumFmt: 15})
@@ -131,10 +140,14 @@ func TestXLSXImport(t *testing.T) {
131140 if err != nil {132141 t.Fatal(err)133142 }134- s := res.Sheet143+ book := res.Sheet.Book()144+ if book.Len() != 3 || res.Sheet.Name() != "Q2" || book.Sheet(2).Name() != "Q3 plan" {145+ t.Fatalf("imported %d sheets, showing %s", book.Len(), res.Sheet.Name())146+ }147+ s := book.Sheet(0)135148 for cell, want := range map[string]string{136149 "A1": "Region", "B1": "1,234.50", "C1": "=not a formula", "D1": "15-Mar-23", "E1": "TRUE",137- "A2": "84", "B2": "123550.00%",150+ "A2": "84", "B2": "123550.00%", "C2": "45", "D2": "7",138151 } {139152 if g := shown(s, addr(t, cell)); g != want {140153 t.Errorf("%s shows %q, want %q", cell, g, want)
@@ -151,14 +164,14 @@ func TestXLSXImport(t *testing.T) {
151164 }152165 notes := strings.Join(res.Notes, "; ")153166 for _, want := range []string{154- "first sheet only, Q1; not imported: Q2, Q3",155- "1 formula kept as values, e.g. A2 =Q2!A1*2",167+ "1 formula kept as values, e.g. D2 =CUBEVALUE(1)",168+ "1 named range left out, e.g. Local",156169 } {157170 if !strings.Contains(notes, want) {158171 t.Errorf("notes %q, want %q", notes, want)159172 }160173 }161- if rows, frac := prog.Get(); rows != 2 {174+ if rows, frac := prog.Get(); rows != 5 {162175 t.Errorf("progress %d rows, %v", rows, frac)163176 }164177 }
@@ -220,6 +233,8 @@ func TestExcelFormulas(t *testing.T) {
220233 {`=JEV.TEST(A2,"Is it?")`, "", false},221234 {"=A1>1 #AND# B1<2", "", false},222235 {"=#REF!+1", "#REF!+1", true},236+ {"='Q3 #1'!A1..B2", "'Q3 #1'!A1:B2", true},237+ {"=SUM('Bob''s'!A1)", "SUM('Bob''s'!A1)", true},223238 } {224239 got, ok := toExcelFormula(tc.in)225240 if ok != tc.ok || ok && got != tc.want {
@@ -230,9 +245,57 @@ func TestExcelFormulas(t *testing.T) {
230245 "SUM(A1:B2)": "=SUM(A1:B2)",231246 "_xlfn.IFNA(_xlfn.XLOOKUP(1,A:A,B:B),0)": "=IFNA(XLOOKUP(1,A:A,B:B),0)",232247 `"_xlfn.kept"&A1`: `="_xlfn.kept"&A1`,248+ "'_xlfn.odd'!A1": "='_xlfn.odd'!A1",233249 } {234250 if got := fromExcelFormula(in); got != want {235251 t.Errorf("fromExcelFormula(%q) = %q, want %q", in, got, want)236252 }237253 }238254 }255+256+// Every sheet goes out to Excel and comes back, with references between257+// sheets, the sheet shown and named ranges.258+func TestXLSXWorkbookRoundTrip(t *testing.T) {259+ src := build(t, map[string]string{"A1": "Rent", "B1": "1450", "B2": "='Q3 plan'!A1*2"})260+ book := src.Book()261+ book.RenameSheet(src, "Budget")262+ plan, _ := book.AddSheet("Q3 plan", 1)263+ plan.Set(addr(t, "A1"), "=Budget!B1+Rent")264+ plan.Set(addr(t, "A2"), `=JEV.TEST(A1, "Big?")`)265+ book.DefineName("Rent", src, sheet.NewRect(addr(t, "B1"), addr(t, "B1")))266+267+ name := filepath.Join(t.TempDir(), "book.xlsx")268+ res, err := Export(context.Background(), name, XLSX, SnapBook(plan), ExportOptions{})269+ if err != nil {270+ t.Fatal(err)271+ }272+ if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "e.g. 'Q3 plan'!A2") {273+ t.Errorf("notes %q", res.Notes)274+ }275+ x, err := excelize.OpenFile(name)276+ if err != nil {277+ t.Fatal(err)278+ }279+ if got, _ := x.GetCellFormula("Budget", "B2"); got != "'Q3 plan'!A1*2" {280+ t.Errorf("Excel formula %q", got)281+ }282+ x.Close()283+284+ got, err := Import(context.Background(), name, Options{})285+ if err != nil {286+ t.Fatal(err)287+ }288+ gb := got.Sheet.Book()289+ if gb.Len() != 2 || got.Sheet.Name() != "Q3 plan" {290+ t.Fatalf("read back %d sheets, showing %s", gb.Len(), got.Sheet.Name())291+ }292+ if v := shown(gb.Sheet(0), addr(t, "B2")); v != "5800" {293+ t.Errorf("Budget!B2 shows %s", v)294+ }295+ if v := input(got.Sheet, addr(t, "A1")); v != "=Budget!B1+Rent" {296+ t.Errorf("Q3 plan!A1 input %s", v)297+ }298+ if n, ok := gb.LookupName("Rent"); !ok || n.Ref() != "Budget!B1" {299+ t.Errorf("name Rent: %v %s", ok, n.Ref())300+ }301+}
M internal/sheet/args.go +10 -12
@@ -68,7 +68,8 @@ func boolArg(args []Node, i int, def bool, get lookup) (bool, *Value) {
6868 type matrix struct {6969 rows, cols int7070 cell func(r, c int) Value71- origin Addr // top-left cell when the block is a reference71+ origin Addr // top-left cell when the block is a reference72+ sheet string // the reference's sheet, as written7273 ref bool7374 }7475
@@ -80,26 +81,23 @@ func (m matrix) size() int { return m.rows * m.cols }
8081 func (m matrix) vector() bool { return m.rows == 1 || m.cols == 1 }8182 8283 func matrixArg(n Node, get lookup) matrix {83- var r Rect8484 switch n := n.(type) {8585 case rangeNode:86- r = n.r86+ return rectMatrix(n.sheet, n.r, get)8787 case refNode:88- r = Rect{From: n.a, To: n.a}89- default:90- v := eval(n, get)91- return matrix{rows: 1, cols: 1, cell: func(int, int) Value { return v }}88+ return rectMatrix(n.sheet, Rect{From: n.a, To: n.a}, get)9289 }93- return rectMatrix(r, get)90+ v := eval(n, get)91+ return matrix{rows: 1, cols: 1, cell: func(int, int) Value { return v }}9492 }9593 96-func rectMatrix(r Rect, get lookup) matrix {94+func rectMatrix(sheet string, r Rect, get lookup) matrix {9795 return matrix{9896 rows: r.To.Row - r.From.Row + 1, cols: r.To.Col - r.From.Col + 1,9997 cell: func(row, col int) Value {100- return get(Addr{Col: r.From.Col + col, Row: r.From.Row + row})98+ return get(sheet, Addr{Col: r.From.Col + col, Row: r.From.Row + row})10199 },102- origin: r.From, ref: true,100+ origin: r.From, sheet: sheet, ref: true,103101 }104102 }105103
@@ -110,7 +108,7 @@ func (m matrix) resized(rows, cols int, get lookup) matrix {
110108 return m111109 }112110 to := Addr{Col: m.origin.Col + cols - 1, Row: m.origin.Row + rows - 1}113- return rectMatrix(Rect{From: m.origin, To: to}, get)111+ return rectMatrix(m.sheet, Rect{From: m.origin, To: to}, get)114112 }115113 116114 // nums collects the numbers in args with aggregate semantics: in ranges
M internal/sheet/cellformat.go +3 -3
M internal/sheet/decimal.go +15 -11
@@ -224,29 +224,33 @@ func decimalize(n Node) Node {
224224 225225 // arith returns the formula to evaluate under the workbook's arithmetic226226 // setting.227-func (s *Sheet) arith(n Node) Node {228- if s.view.decimal {227+func (w *Workbook) arith(n Node) Node {228+ if w.decimal {229229 return decimalize(n)230230 }231231 return n232232 }233233 234234 // Decimal reports whether the workbook computes in decimal.235-func (s *Sheet) Decimal() bool { return s.view.decimal }235+func (w *Workbook) Decimal() bool { return w.decimal }236236 237-// SetDecimal turns decimal arithmetic on or off, as one undo step, and238-// recalculates every formula.239-func (s *Sheet) SetDecimal(on bool) {240- if on == s.view.decimal {237+// SetDecimal turns decimal arithmetic on or off for every sheet, as one238+// undo step, and recalculates every formula.239+func (w *Workbook) SetDecimal(on bool) {240+ if on == w.decimal {241241 return242242 }243243 label := "turn off decimal arithmetic"244244 if on {245245 label = "turn on decimal arithmetic"246246 }247- s.change(label, Rect{}, func() {248- s.recordView()249- s.view.decimal = on250- s.hist.dirty = append(s.hist.dirty, s.Addrs()...)247+ w.change(w.sheets[w.Active()], label, Rect{}, func() {248+ w.recordDecimal()249+ w.decimal = on250+ w.structural = true // every formula computes differently251251 })252252 }253+254+// Decimal and SetDecimal on a sheet are its workbook's.255+func (s *Sheet) Decimal() bool { return s.wb.Decimal() }256+func (s *Sheet) SetDecimal(on bool) { s.wb.SetDecimal(on) }
M internal/sheet/eval.go +6 -4
M internal/sheet/explain.go +49 -17
@@ -13,33 +13,55 @@ func (s *Sheet) ExplainError(a Addr) string {
1313 return s.explain(a, nil)1414 }1515 16+// label names a cell for an explanation, with its sheet when it isn't on17+// the sheet being explained: B3 or Sheet2!B3.18+func (l loc) label(from *Sheet) string {19+ if l.s == from {20+ return l.a.String()21+ }22+ return quoteSheet(l.s.name) + "!" + l.a.String()23+}24+1625 // maxExplainExpr caps how much of a formula an explanation quotes.1726 const maxExplainExpr = 401827 19-func (s *Sheet) explain(a Addr, path []Addr) string {28+func (s *Sheet) explain(a Addr, path []loc) string {29+ here := loc{s, a}2030 c := s.cells[a]2131 v := s.Value(a)2232 if c == nil || v.Kind != Error || !c.IsFormula() {2333 return ""2434 }25- if i := slices.Index(path, a); i >= 0 {35+ // Cells are named relative to the sheet the explanation starts on.36+ home := s37+ if len(path) > 0 {38+ home = path[0].s39+ }40+ if i := slices.Index(path, here); i >= 0 {2641 names := make([]string, 0, len(path)-i+1)2742 for _, p := range path[i:] {28- names = append(names, p.String())43+ names = append(names, p.label(home))2944 }30- return "Circular reference: " + strings.Join(append(names, a.String()), " → ")45+ return "Circular reference: " + strings.Join(append(names, here.label(home)), " → ")3146 }32- path = append(path, a)47+ path = append(path, here)3348 n, from := s.errorOrigin(c.expr, v)3449 if from != nil {35- why := s.explain(*from, path)50+ why := from.s.explain(from.a, path)51+ name := from.label(home)3652 if why == "" {37- return "From " + from.String()53+ return "From " + name3854 }3955 if strings.HasPrefix(why, "Circular") || strings.HasPrefix(why, "From ") {4056 return why4157 }42- return "From " + from.String() + ": " + strings.ToLower(why[:1]) + why[1:]58+ return "From " + name + ": " + strings.ToLower(why[:1]) + why[1:]59+ }60+ if r, ok := n.(refNode); ok && s.wb.resolve(s, r.sheet) == nil {61+ return "Unresolved sheet name " + quoteSheet(r.sheet)62+ }63+ if r, ok := n.(rangeNode); ok && s.wb.resolve(s, r.sheet) == nil {64+ return "Unresolved sheet name " + quoteSheet(r.sheet)4365 }4466 return describeError(v, n)4567 }
@@ -47,17 +69,23 @@ func (s *Sheet) explain(a Addr, path []Addr) string {
4769 // errorOrigin finds the innermost part of n that produces the error want4870 // itself rather than passing it on. When the error comes from a4971 // referenced cell, it returns that cell.50-func (s *Sheet) errorOrigin(n Node, want Value) (Node, *Addr) {72+func (s *Sheet) errorOrigin(n Node, want Value) (Node, *loc) {5173 same := func(v Value) bool { return v.Kind == Error && v.Str == want.Str }52- get := s.Value74+ get := s.wb.values(s)75+ at := func(sheet string, a Addr) *loc {76+ if t := s.wb.resolve(s, sheet); t != nil {77+ return &loc{t, a}78+ }79+ return nil // an unresolved sheet name is the error itself80+ }5381 switch n := n.(type) {5482 case refNode:55- if same(get(n.a)) {56- return n, &n.a83+ if same(get(n.sheet, n.a)) {84+ return n, at(n.sheet, n.a)5785 }5886 case rangeNode:59- if n.r.From == n.r.To && same(get(n.r.From)) {60- return n, &n.r.From87+ if n.r.From == n.r.To && same(get(n.sheet, n.r.From)) {88+ return n, at(n.sheet, n.r.From)6189 }6290 case unaryNode:6391 if same(eval(n.x, get)) {
@@ -75,11 +103,15 @@ func (s *Sheet) errorOrigin(n Node, want Value) (Node, *Addr) {
75103 }76104 for _, arg := range n.args {77105 if r, ok := arg.(rangeNode); ok && r.r.From != r.r.To {78- cells := s.cellsIn(r.r)106+ t := s.wb.resolve(s, r.sheet)107+ if t == nil {108+ return arg, nil109+ }110+ cells := t.cellsIn(r.r)79111 sortAddrs(cells)80112 for _, a := range cells {81- if same(get(a)) {82- return arg, &a113+ if same(t.Value(a)) {114+ return arg, &loc{t, a}83115 }84116 }85117 continue
M internal/sheet/file.go +210 -56
@@ -7,6 +7,7 @@ import (
77 "io"88 "maps"99 "slices"10+ "strings"1011 )1112 1213 // FileExt is the extension of the native worksheet format.
@@ -16,7 +17,10 @@ const FileExt = ".012"
1617 // Version 1 files (cells as plain strings, no formatting) still load.1718 // Version 3 adds named ranges, frozen panes and a filter; a sheet using1819 // none of them is written as version 2, so earlier builds can open it.19-const fileVersion = 320+// Version 4 holds several sheets; a workbook of one sheet whose formulas21+// name no sheet is still written as version 2 or 3, its sheet's name in22+// a "name" field that earlier builds ignore.23+const fileVersion = 42024 2125 // The file is JSON with one entry per cell, keyed by address. A cell2226 // without formatting is just its input, as in version 1; a formatted cell
@@ -26,11 +30,31 @@ const fileVersion = 3
2630 // "C2": {"input": "1450", "format": "currency", "decimals": 2, "bold": true}2731 //2832 // Named ranges map each name to its range, or to "#REF!" once its cells29-// were deleted: "names": {"Sales": "B2:B20"}.33+// were deleted: "names": {"Sales": "B2:B20"}. In version 4 the sheets are34+// a list, each with its name and the fields a version 3 file has at the35+// top, and ranges of names say their sheet: {"Sales": "Q3!B2:B20"}.36+//37+// "sheets": [38+// {39+// "name": "Q3",40+// "cells": {41+// "A1": "Rent",3042 type fileFormat struct {3143 Version int `json:"version"`32- Widths map[string]int `json:"widths,omitempty"`3344 Names map[string]string `json:"names,omitempty"`45+ Active int `json:"active,omitempty"` // index of the sheet shown46+ // Arithmetic is "decimal" for decimal arithmetic, which needs no47+ // version bump: earlier builds ignore it and compute in binary, as48+ // Sheets would. It is for the whole workbook, so it stays at the top.49+ Arithmetic string `json:"arithmetic,omitempty"`50+ fileSheet // versions 1 to 3: the only sheet51+ Sheets []fileSheet `json:"sheets,omitempty"` // version 452+}53+54+// fileSheet is one sheet of a file.55+type fileSheet struct {56+ Name string `json:"name,omitempty"`57+ Widths map[string]int `json:"widths,omitempty"`3458 fileView3559 Cells map[string]json.RawMessage `json:"cells"`3660 Charts []fileChart `json:"charts,omitempty"`
@@ -141,37 +165,113 @@ func decodeCell(raw json.RawMessage) (string, Format, Style, error) {
141165 return fc.Input, f, st, nil142166 }143167 144-// Write saves the worksheet as JSON, storing each cell's input as typed168+// Write saves the workbook the sheet belongs to; see Workbook.Write.169+func (s *Sheet) Write(w io.Writer) error { return s.wb.Write(w) }170+171+// Write saves the workbook as JSON, storing each cell's input as typed145172 // and its formatting. Cells go one per line in row-major order so diffs146173 // read naturally.147-func (s *Sheet) Write(w io.Writer) error {174+func (w *Workbook) Write(out io.Writer) error {148175 var b bytes.Buffer149- version := 2150- if v := s.view; len(s.names) > 0 || v.frozenRows > 0 || v.frozenCols > 0 || v.filter != nil {151- version = 3152- }153- fmt.Fprintf(&b, "{\n \"version\": %d,\n", version)154- if len(s.widths) > 0 {155- b.WriteString(` "widths": {`)156- for i, c := range slices.Sorted(maps.Keys(s.widths)) {176+ if w.single() {177+ s := w.sheets[0]178+ version := 2179+ if v := s.view; len(w.names) > 0 || v.frozenRows > 0 || v.frozenCols > 0 || v.filter != nil {180+ version = 3181+ }182+ fmt.Fprintf(&b, "{\n \"version\": %d,\n", version)183+ if s.name != "Sheet1" {184+ fmt.Fprintf(&b, " \"name\": %s,\n", jsonString(s.name))185+ }186+ if err := s.writeBody(&b, " ", w.headLines()); err != nil {187+ return err188+ }189+ b.WriteString("\n}\n")190+ } else {191+ fmt.Fprintf(&b, "{\n \"version\": %d,\n", fileVersion)192+ if head := w.headLines(); head != "" {193+ b.WriteString(" " + head + ",\n")194+ }195+ if w.Active() > 0 {196+ fmt.Fprintf(&b, " \"active\": %d,\n", w.Active())197+ }198+ b.WriteString(` "sheets": [`)199+ for i, s := range w.sheets {157200 if i > 0 {158- b.WriteString(", ")201+ b.WriteByte(',')202+ }203+ fmt.Fprintf(&b, "\n {\n \"name\": %s,\n", jsonString(s.name))204+ if err := s.writeBody(&b, " ", ""); err != nil {205+ return err159206 }160- fmt.Fprintf(&b, "%q: %d", ColName(c), s.widths[c])207+ b.WriteString("\n }")161208 }162- b.WriteString("},\n")209+ b.WriteString("\n ]\n}\n")210+ }211+ _, err := out.Write(b.Bytes())212+ return err213+}214+215+// single reports whether the workbook fits the single-sheet format of216+// versions 2 and 3: one sheet, and no formula naming a sheet.217+func (w *Workbook) single() bool {218+ return len(w.sheets) == 1 && len(w.crossUsers) == 0219+}220+221+func jsonString(s string) string {222+ raw, _ := json.Marshal(s)223+ return string(raw)224+}225+226+// headLines are the workbook's fields before the sheets: the named ranges227+// and the arithmetic setting, separated as the lines of the file.228+func (w *Workbook) headLines() string {229+ var lines []string230+ if names := w.namesLine(); names != "" {231+ lines = append(lines, names)163232 }164- if names := s.Names(); len(names) > 0 {165- b.WriteString(` "names": {`)166- for i, n := range names {233+ if w.decimal {234+ lines = append(lines, `"arithmetic": "decimal"`)235+ }236+ return strings.Join(lines, ",\n ")237+}238+239+// namesLine is the "names" field, or "" without names.240+func (w *Workbook) namesLine() string {241+ names := w.Names()242+ if len(names) == 0 {243+ return ""244+ }245+ var b strings.Builder246+ b.WriteString(`"names": {`)247+ for i, n := range names {248+ if i > 0 {249+ b.WriteString(", ")250+ }251+ fmt.Fprintf(&b, "%q: %s", n.Name, jsonString(n.Ref()))252+ }253+ b.WriteString("}")254+ return b.String()255+}256+257+// writeBody writes a sheet's fields, each line starting with indent, the258+// names line (if any) after the widths as version 3 has it, and no259+// newline after the last field.260+func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {261+ if len(s.widths) > 0 {262+ b.WriteString(indent + `"widths": {`)263+ for i, c := range slices.Sorted(maps.Keys(s.widths)) {167264 if i > 0 {168265 b.WriteString(", ")169266 }170- fmt.Fprintf(&b, "%q: %q", n.Name, n.Ref())267+ fmt.Fprintf(b, "%q: %d", ColName(c), s.widths[c])171268 }172269 b.WriteString("},\n")173270 }174- if err := s.writeView(&b); err != nil {271+ if names != "" {272+ b.WriteString(indent + names + ",\n")273+ }274+ if err := s.writeView(b, indent); err != nil {175275 return err176276 }177277 addrs := make([]Addr, 0, len(s.cells))
@@ -179,7 +279,7 @@ func (s *Sheet) Write(w io.Writer) error {
179279 addrs = append(addrs, a)180280 }181281 sortAddrs(addrs)182- b.WriteString(` "cells": {`)282+ b.WriteString(indent + `"cells": {`)183283 for i, a := range addrs {184284 raw, err := encodeCell(s.cells[a])185285 if err != nil {
@@ -188,14 +288,14 @@ func (s *Sheet) Write(w io.Writer) error {
188288 if i > 0 {189289 b.WriteByte(',')190290 }191- fmt.Fprintf(&b, "\n %q: %s", a.String(), raw)291+ fmt.Fprintf(b, "\n%s %q: %s", indent, a.String(), raw)192292 }193293 if len(addrs) > 0 {194- b.WriteString("\n ")294+ b.WriteString("\n" + indent)195295 }196296 b.WriteString("}")197297 if len(s.charts) > 0 {198- b.WriteString(",\n \"charts\": [")298+ b.WriteString(",\n" + indent + `"charts": [`)199299 for i, c := range s.charts {200300 raw, err := encodeChart(c)201301 if err != nil {
@@ -204,17 +304,26 @@ func (s *Sheet) Write(w io.Writer) error {
204304 if i > 0 {205305 b.WriteByte(',')206306 }207- fmt.Fprintf(&b, "\n %s", raw)307+ fmt.Fprintf(b, "\n%s %s", indent, raw)208308 }209- b.WriteString("\n ]")309+ b.WriteString("\n" + indent + "]")210310 }211- b.WriteString("\n}\n")212- _, err := w.Write(b.Bytes())213- return err311+ return nil214312 }215313 216-// Read loads a worksheet written by Write, of this or an earlier version.314+// Read loads a file written by Write, of this or an earlier version, and315+// returns the sheet that was shown when it was saved.217316 func Read(r io.Reader) (*Sheet, error) {317+ w, err := ReadBook(r)318+ if err != nil {319+ return nil, err320+ }321+ return w.Sheet(w.Active()), nil322+}323+324+// ReadBook loads a workbook written by Write, of this or an earlier325+// version.326+func ReadBook(r io.Reader) (*Workbook, error) {218327 var f fileFormat219328 if err := json.NewDecoder(r).Decode(&f); err != nil {220329 return nil, err
@@ -222,58 +331,103 @@ func Read(r io.Reader) (*Sheet, error) {
222331 if f.Version < 1 || f.Version > fileVersion {223332 return nil, fmt.Errorf("unsupported file version %d", f.Version)224333 }225- s := New()226- for name, width := range f.Widths {227- c, ok := ParseCol(name)228- if !ok {229- return nil, fmt.Errorf("invalid column %q", name)334+ bodies := f.Sheets335+ if f.Version < 4 {336+ bodies = []fileSheet{f.fileSheet}337+ if bodies[0].Name == "" {338+ bodies[0].Name = "Sheet1"230339 }231- s.SetColWidth(c, width)232340 }233- for name, ref := range f.Names {341+ if len(bodies) == 0 {342+ return nil, fmt.Errorf("the file has no sheets")343+ }344+ w := emptyBook()345+ for _, body := range bodies {346+ if err := w.checkName(nil, body.Name); err != nil {347+ return nil, fmt.Errorf("sheet %q: %w", body.Name, err)348+ }349+ w.insert(w.newSheet(body.Name), len(w.sheets))350+ }351+ if err := w.readNames(f.Names); err != nil {352+ return nil, err353+ }354+ for i, body := range bodies {355+ if err := w.sheets[i].read(body, f.Version); err != nil {356+ if len(bodies) > 1 {357+ err = fmt.Errorf("sheet %s: %w", body.Name, err)358+ }359+ return nil, err360+ }361+ }362+ w.active = clampInt(f.Active, 0, len(w.sheets)-1)363+ // Anything but "decimal" (say, a mode from a later build) computes in364+ // binary, as the file would in a build without the setting.365+ w.decimal = f.Arithmetic == "decimal"366+ w.RecalcAll()367+ return w, nil368+}369+370+// readNames defines the named ranges of a file. A range without a sheet371+// is on the first sheet, as in single-sheet files.372+func (w *Workbook) readNames(names map[string]string) error {373+ for name, ref := range names {234374 if err := ValidName(name); err != nil {235- return nil, fmt.Errorf("name %q: %w", name, err)375+ return fmt.Errorf("name %q: %w", name, err)236376 }237- if _, dup := s.LookupName(name); dup {238- return nil, fmt.Errorf("name %q is defined twice", name)377+ if _, dup := w.LookupName(name); dup {378+ return fmt.Errorf("name %q is defined twice", name)239379 }240- n := Name{Name: name, Lost: ref == "#REF!"}380+ n := Name{Name: name, Sheet: w.sheets[0], Lost: ref == "#REF!"}241381 if !n.Lost {242- r, ok := ParseRange(ref)382+ sheet, rest := SplitSheet(ref)383+ if sheet != "" {384+ if n.Sheet = w.Lookup(sheet); n.Sheet == nil {385+ return fmt.Errorf("name %q: no sheet %q", name, sheet)386+ }387+ }388+ r, ok := ParseRange(rest)243389 if !ok {244- return nil, fmt.Errorf("name %q: invalid range %q", name, ref)390+ return fmt.Errorf("name %q: invalid range %q", name, ref)245391 }246392 n.Range = r247393 }248- s.putName(nameKey(name), &n)394+ w.putName(nameKey(name), &n)395+ }396+ return nil397+}398+399+// read fills an empty sheet from its part of a file.400+func (s *Sheet) read(f fileSheet, version int) error {401+ for name, width := range f.Widths {402+ c, ok := ParseCol(name)403+ if !ok {404+ return fmt.Errorf("invalid column %q", name)405+ }406+ s.setWidth(c, width)249407 }250408 for name, raw := range f.Cells {251409 a, ok := ParseAddr(name)252410 if !ok {253- return nil, fmt.Errorf("invalid cell %q", name)411+ return fmt.Errorf("invalid cell %q", name)254412 }255413 input, fm, st, err := decodeCell(raw)256414 if err != nil {257- return nil, fmt.Errorf("%s: %w", name, err)415+ return fmt.Errorf("%s: %w", name, err)258416 }259417 // Version 1 had no formats, so there entries imply them, as if260- // typed again; in version 2 the stored format wins.261- c, err := newCell(input, fm, st, f.Version < 2)418+ // typed again; since version 2 the stored format wins.419+ c, err := newCell(input, fm, st, version < 2)262420 if err != nil {263- return nil, fmt.Errorf("%s: %w", name, err)421+ return fmt.Errorf("%s: %w", name, err)264422 }265423 s.place(a, c)266424 }267425 for i, fc := range f.Charts {268426 c, err := decodeChart(fc)269427 if err != nil {270- return nil, fmt.Errorf("chart %d: %w", i+1, err)428+ return fmt.Errorf("chart %d: %w", i+1, err)271429 }272430 s.charts = append(s.charts, c)273431 }274- if err := s.readView(f.fileView); err != nil {275- return nil, err276- }277- s.RecalcAll()278- return s, nil432+ return s.readView(f.fileView)279433 }
M internal/sheet/functions.go +2 -2
M internal/sheet/functions_jev.go +2 -2
M internal/sheet/functions_link.go +1 -1
M internal/sheet/history.go +225 -101
@@ -1,13 +1,18 @@
11 package sheet22 3-import "slices"3+import (4+ "maps"5+ "slices"6+)47 5-// MaxUndo is how many steps of undo history a sheet keeps.8+// MaxUndo is how many steps of undo history a workbook keeps.69 const MaxUndo = 100710 8-// History is a command log of before-images. Each step records, for every9-// cell and column width it touched, the state before the step; undoing10-// swaps those back in and records the state it replaced as the redo step.11+// History is a command log of before-images, shared by every sheet of a12+// workbook as in Sheets. Each step records, for every cell, column width,13+// named range, view, chart list and the sheet list it touched, the state14+// before the step; undoing swaps those back in and records the state it15+// replaced as the redo step.1116 //1217 // Steps snapshot whole cells (a shallow copy of the struct), so fields1318 // added to Cell later are covered without changes here. That relies on
@@ -18,7 +23,7 @@ type history struct {
1823 undo, redo []*step1924 open *step // the step being built; nil outside change2025 depth int // nesting of change calls21- dirty []Addr26+ dirty []loc2227 lastID int2328 // mergeWidths lets the next width-only step join the top one, so a2429 // live preview of a column width and its final value (or its
@@ -29,22 +34,44 @@ type history struct {
2934 type step struct {3035 id int3136 label string // what the step did, e.g. "clear B3:B5"32- focus Rect // what the UI selects when the step is undone or redone33- cells map[Addr]*Cell // before the step; nil for blank34- widths map[int]int // before the step; 0 for the default width37+ sheet *Sheet // the sheet the UI shows when the step is undone or redone38+ focus Rect // what the UI selects there39+ cells map[loc]*Cell // before the step; nil for blank40+ widths map[colKey]int // before the step; 0 for the default width3541 names map[string]*Name // before the step, by key; nil for undefined36- view *viewState // before the step; nil if the step didn't touch it37- // charts is every chart before the step, when it changed any.38- charts []Chart39- chartsSaved bool42+ views map[*Sheet]*viewState43+ // charts holds each touched sheet's charts before the step.44+ charts map[*Sheet][]Chart45+ // sheets is the sheet list before the step, when it changed.46+ sheets *sheetList47+ // decimal is the arithmetic setting before the step, when it changed.48+ decimal *bool4049 }4150 42-func newStep(label string, focus Rect) *step {43- return &step{label: label, focus: focus, cells: map[Addr]*Cell{}, widths: map[int]int{}, names: map[string]*Name{}}51+// colKey is a column of a sheet.52+type colKey struct {53+ s *Sheet54+ col int55+}56+57+// sheetList is the order and names of the sheets.58+type sheetList struct {59+ order []*Sheet60+ names map[*Sheet]string61+}62+63+func newStep(label string, s *Sheet, focus Rect) *step {64+ return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{},65+ names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}}4466 }4567 4668 func (st *step) empty() bool {47- return len(st.cells) == 0 && len(st.widths) == 0 && len(st.names) == 0 && st.view == nil && !st.chartsSaved69+ return len(st.cells) == 0 && len(st.widths) == 0 && st.widthOnly()70+}71+72+// widthOnly reports whether the step changed nothing but column widths.73+func (st *step) widthOnly() bool {74+ return len(st.cells) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && st.sheets == nil && st.decimal == nil4875 }4976 5077 func (c *Cell) clone() *Cell {
@@ -56,10 +83,12 @@ func (c *Cell) clone() *Cell {
5683 }5784 5885 // Change describes an undo step: what it did, in lower case for use in a59-// sentence ("clear B3:B5"), and the range it affected.86+// sentence ("clear B3:B5"), the sheet it happened on and the range it87+// affected there.6088 type Change struct {6189 Label string6290 Focus Rect91+ Sheet *Sheet6392 }6493 6594 // Batch runs fn as a single undo step: everything it changes is undone
@@ -71,12 +100,18 @@ func (s *Sheet) Batch(c Change, fn func() error) error {
71100 return err72101 }73102 103+// change runs fn as an undo step on this sheet; see Workbook.change.104+func (s *Sheet) change(label string, focus Rect, fn func()) {105+ s.wb.change(s, label, focus, fn)106+}107+74108 // change opens a step (unless one is open), runs fn, and on the way out75109 // of the outermost call recalculates what changed and pushes the step.76-func (s *Sheet) change(label string, focus Rect, fn func()) {77- h := &s.hist110+func (w *Workbook) change(s *Sheet, label string, focus Rect, fn func()) {111+ h := &w.hist78112 if h.depth == 0 {79- h.open = newStep(label, focus)113+ h.open = newStep(label, s, focus)114+ w.structural = false80115 }81116 h.depth++82117 defer func() {
@@ -86,96 +121,166 @@ func (s *Sheet) change(label string, focus Rect, fn func()) {
86121 }87122 st, dirty := h.open, h.dirty88123 h.open, h.dirty = nil, nil89- s.recalc(dirty)90- s.push(st)124+ if w.structural {125+ w.structural = false126+ w.recalcAll()127+ } else {128+ w.recalc(dirty)129+ }130+ w.push(st)91131 }()92132 fn()93133 }94134 95135 // record saves the cell at a before its first change in the open step.96136 func (s *Sheet) record(a Addr) {97- st := s.hist.open137+ st := s.wb.hist.open98138 if st == nil {99139 return100140 }101- if _, seen := st.cells[a]; !seen {102- st.cells[a] = s.cells[a].clone()141+ l := loc{s, a}142+ if _, seen := st.cells[l]; !seen {143+ st.cells[l] = s.cells[a].clone()103144 }104- s.hist.dirty = append(s.hist.dirty, a)145+ s.wb.hist.dirty = append(s.wb.hist.dirty, l)105146 }106147 107148 // recordWidth saves column c's width before its first change in the open108149 // step.109150 func (s *Sheet) recordWidth(c int) {110- st := s.hist.open151+ st := s.wb.hist.open111152 if st == nil {112153 return113154 }114- if _, seen := st.widths[c]; !seen {115- st.widths[c] = s.widths[c]155+ k := colKey{s, c}156+ if _, seen := st.widths[k]; !seen {157+ st.widths[k] = s.widths[c]116158 }117159 }118160 119161 // recordName saves the named range with key k before its first change in120162 // the open step.121-func (s *Sheet) recordName(k string) {122- st := s.hist.open163+func (w *Workbook) recordName(k string) {164+ st := w.hist.open123165 if st == nil {124166 return125167 }126168 if _, seen := st.names[k]; !seen {127- st.names[k] = s.namePtr(k)169+ st.names[k] = w.namePtr(k)128170 }129171 }130172 131-// recordCharts saves the charts before their first change in the open132-// step. Charts are few, so the step keeps them all.173+// recordCharts saves the sheet's charts before their first change in the174+// open step. Charts are few, so the step keeps them all.133175 func (s *Sheet) recordCharts() {134- if st := s.hist.open; st != nil && !st.chartsSaved {135- st.charts, st.chartsSaved = slices.Clone(s.charts), true176+ if st := s.wb.hist.open; st != nil {177+ if _, seen := st.charts[s]; !seen {178+ st.charts[s] = slices.Clone(s.charts)179+ }136180 }137181 }138182 183+// recordSheets saves the sheet list before its first change in the open184+// step, and has the step recalculate everything when it ends.185+func (w *Workbook) recordSheets() {186+ w.structural = true187+ if st := w.hist.open; st != nil && st.sheets == nil {188+ st.sheets = w.sheetList()189+ }190+}191+192+// recordDecimal saves the arithmetic setting before its first change in193+// the open step.194+func (w *Workbook) recordDecimal() {195+ if st := w.hist.open; st != nil && st.decimal == nil {196+ d := w.decimal197+ st.decimal = &d198+ }199+}200+201+func (w *Workbook) sheetList() *sheetList {202+ l := &sheetList{order: slices.Clone(w.sheets), names: map[*Sheet]string{}}203+ for _, s := range w.sheets {204+ l.names[s] = s.name205+ }206+ return l207+}208+209+// setSheets restores a sheet list: sheets not in it are detached, the210+// rest renamed and attached in its order.211+func (w *Workbook) setSheets(l *sheetList) {212+ for _, s := range w.sheets {213+ if !slices.Contains(l.order, s) {214+ w.detach(s)215+ }216+ }217+ w.byKey = map[string]*Sheet{}218+ w.sheets = slices.Clone(l.order)219+ for _, s := range w.sheets {220+ s.name = l.names[s]221+ if s.live {222+ w.byKey[sheetKey(s.name)] = s223+ } else {224+ w.attach(s)225+ }226+ }227+ w.structural = true228+}229+230+func (l *sheetList) equal(m *sheetList) bool {231+ return slices.Equal(l.order, m.order) && maps.Equal(l.names, m.names)232+}233+139234 // push adds a finished step to the undo stack and clears redo, dropping140235 // no-op changes.141-func (s *Sheet) push(st *step) {142- h := &s.hist143- for c, w := range st.widths {144- if s.widths[c] == w {145- delete(st.widths, c)236+func (w *Workbook) push(st *step) {237+ h := &w.hist238+ for k, width := range st.widths {239+ if k.s.widths[k.col] == width {240+ delete(st.widths, k)146241 }147242 }148- for a, c := range st.cells {149- if c == nil && s.cells[a] == nil {150- delete(st.cells, a)243+ for l, c := range st.cells {244+ if c == nil && l.s.cells[l.a] == nil {245+ delete(st.cells, l)151246 }152247 }153248 for k, n := range st.names {154- if cur := s.namePtr(k); n == nil && cur == nil || n != nil && cur != nil && *n == *cur {249+ if cur := w.namePtr(k); n == nil && cur == nil || n != nil && cur != nil && *n == *cur {155250 delete(st.names, k)156251 }157252 }158- if st.chartsSaved && slices.Equal(st.charts, s.charts) {159- st.charts, st.chartsSaved = nil, false253+ for s, charts := range st.charts {254+ if slices.Equal(charts, s.charts) {255+ delete(st.charts, s)256+ }257+ }258+ for s, v := range st.views {259+ if v.equal(s.view) {260+ delete(st.views, s)261+ }262+ }263+ if st.sheets != nil && st.sheets.equal(w.sheetList()) {264+ st.sheets = nil160265 }161- if st.view != nil && st.view.equal(s.view) {162- st.view = nil266+ if st.decimal != nil && *st.decimal == w.decimal {267+ st.decimal = nil163268 }164269 if st.empty() {165270 return166271 }167272 h.redo = nil168273 h.lastID++169- widthOnly := len(st.cells) == 0 && len(st.names) == 0 && st.view == nil && !st.chartsSaved170- if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 && len(top.names) == 0 && top.view == nil && !top.chartsSaved {171- for c, w := range st.widths {172- if _, ok := top.widths[c]; !ok {173- top.widths[c] = w274+ widthOnly := st.widthOnly()275+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && top.widthOnly() {276+ for k, width := range st.widths {277+ if _, ok := top.widths[k]; !ok {278+ top.widths[k] = width174279 }175280 }176- for c, w := range top.widths {177- if s.widths[c] == w {178- delete(top.widths, c)281+ for k, width := range top.widths {282+ if k.s.widths[k.col] == width {283+ delete(top.widths, k)179284 }180285 }181286 top.id, top.focus = h.lastID, union(top.focus, st.focus)
@@ -201,78 +306,97 @@ func (h *history) top() *step {
201306 202307 // Seal ends a run of column width changes, so the next one starts a new203308 // undo step. The UI calls it between user actions.204-func (s *Sheet) Seal() { s.hist.mergeWidths = false }309+func (w *Workbook) Seal() { w.hist.mergeWidths = false }205310 206311 // Undo reverts the last step and describes it.207-func (s *Sheet) Undo() (Change, bool) {208- return s.swap(&s.hist.undo, &s.hist.redo)209-}312+func (w *Workbook) Undo() (Change, bool) { return w.swap(&w.hist.undo, &w.hist.redo) }210313 211314 // Redo reapplies the last undone step and describes it.212-func (s *Sheet) Redo() (Change, bool) {213- return s.swap(&s.hist.redo, &s.hist.undo)214-}315+func (w *Workbook) Redo() (Change, bool) { return w.swap(&w.hist.redo, &w.hist.undo) }215316 216317 // CanUndo and CanRedo report whether there is a step to undo or redo.217-func (s *Sheet) CanUndo() bool { return len(s.hist.undo) > 0 }218-func (s *Sheet) CanRedo() bool { return len(s.hist.redo) > 0 }318+func (w *Workbook) CanUndo() bool { return len(w.hist.undo) > 0 }319+func (w *Workbook) CanRedo() bool { return len(w.hist.redo) > 0 }219320 220-// StateID identifies the sheet's contents in its undo history: undoing321+// StateID identifies the workbook's contents in its undo history: undoing221322 // back to a saved state returns the ID it had when saved, so the UI can222323 // tell whether there are unsaved changes.223-func (s *Sheet) StateID() int {224- if top := s.hist.top(); top != nil {324+func (w *Workbook) StateID() int {325+ if top := w.hist.top(); top != nil {225326 return top.id226327 }227328 return 0228329 }229330 331+// ClearHistory forgets all undo and redo steps.332+func (w *Workbook) ClearHistory() { w.hist = history{} }333+334+// The sheet's history methods are the workbook's, for callers that work335+// with one sheet at a time.336+337+func (s *Sheet) Seal() { s.wb.Seal() }338+func (s *Sheet) Undo() (Change, bool) { return s.wb.Undo() }339+func (s *Sheet) Redo() (Change, bool) { return s.wb.Redo() }340+func (s *Sheet) CanUndo() bool { return s.wb.CanUndo() }341+func (s *Sheet) CanRedo() bool { return s.wb.CanRedo() }342+func (s *Sheet) StateID() int { return s.wb.StateID() }343+func (s *Sheet) ClearHistory() { s.wb.ClearHistory() }344+230345 // swap pops a step from one stack, restores its before-image, and pushes231346 // the state it replaced onto the other stack.232-func (s *Sheet) swap(from, to *[]*step) (Change, bool) {233- if len(*from) == 0 || s.hist.open != nil {347+func (w *Workbook) swap(from, to *[]*step) (Change, bool) {348+ if len(*from) == 0 || w.hist.open != nil {234349 return Change{}, false235350 }236351 st := (*from)[len(*from)-1]237352 *from = (*from)[:len(*from)-1]238353 // The inverse keeps the step's ID: on the redo stack, an ID names the239354 // state the step leads back to.240- inv := newStep(st.label, st.focus)355+ inv := newStep(st.label, st.sheet, st.focus)241356 inv.id = st.id242- changed := make([]Addr, 0, len(st.cells))243- for a, c := range st.cells {244- inv.cells[a] = s.cells[a].clone()245- s.place(a, c.clone())246- changed = append(changed, a)357+ // The sheet list goes first, so cells land on attached sheets.358+ if st.sheets != nil {359+ inv.sheets = w.sheetList()360+ w.setSheets(st.sheets)247361 }248- for c, w := range st.widths {249- inv.widths[c] = s.widths[c]250- s.setWidth(c, w)362+ changed := make([]loc, 0, len(st.cells))363+ for l, c := range st.cells {364+ inv.cells[l] = l.s.cells[l.a].clone()365+ l.s.place(l.a, c.clone())366+ changed = append(changed, l)367+ }368+ for k, width := range st.widths {369+ inv.widths[k] = k.s.widths[k.col]370+ k.s.setWidth(k.col, width)251371 }252372 for k, n := range st.names {253- inv.names[k] = s.namePtr(k)254- changed = append(changed, s.putName(k, n)...)255- }256- if st.chartsSaved {257- inv.charts, inv.chartsSaved = s.charts, true258- s.charts = slices.Clone(st.charts)259- }260- if st.view != nil {261- v := s.view262- inv.view, s.view = &v, *st.view263- if v.decimal != s.view.decimal {264- changed = append(changed, s.Addrs()...) // every formula computes differently265- }373+ inv.names[k] = w.namePtr(k)374+ changed = append(changed, w.putName(k, n)...)375+ }376+ for s, charts := range st.charts {377+ inv.charts[s] = s.charts378+ s.charts = slices.Clone(charts)379+ }380+ if st.decimal != nil {381+ cur := w.decimal382+ inv.decimal, w.decimal = &cur, *st.decimal383+ w.structural = true // every formula computes differently384+ }385+ for s, v := range st.views {386+ cur := s.view387+ inv.views[s] = &cur388+ s.view = *v389+ s.hidden.valid = false390+ }391+ if w.structural {392+ w.structural = false393+ w.recalcAll()394+ } else {395+ w.recalc(changed)266396 }267- s.recalc(changed)268397 *to = append(*to, inv)269- s.hist.mergeWidths = false270- return Change{st.label, st.focus}, true271-}272-273-// ClearHistory forgets all undo and redo steps.274-func (s *Sheet) ClearHistory() {275- s.hist = history{}398+ w.hist.mergeWidths = false399+ return Change{st.label, st.focus, st.sheet}, true276400 }277401 278402 // colRect is the range covering whole columns from..to.
M internal/sheet/history_test.go +2 -2
M internal/sheet/names.go +76 -48
@@ -14,19 +14,29 @@ import (
1414 // recalculates every formula that uses it. Inserting, deleting and moving1515 // cells moves a name's range like any other range reference.1616 17-// Name is a named range.17+// Name is a named range. Names belong to the workbook, as in Sheets, and18+// each points at a range on one of its sheets.1819 type Name struct {1920 Name string // as the user spelled it; formulas match it in any case21+ Sheet *Sheet // the sheet the range is on2022 Range Rect2123 // Lost is set when every cell of the range was deleted: formulas using2224 // the name show #REF!, as in Sheets. Range is then zero.2325 Lost bool2426 }2527 26-// Ref is what the name stands for, e.g. "B2:B20", or "#REF!" when lost.28+// Gone reports whether the name no longer points at cells: its cells or29+// its sheet were deleted.30+func (n Name) Gone() bool { return n.Lost || n.Sheet == nil || !n.Sheet.live }31+32+// Ref is what the name stands for, e.g. "B2:B20", or "#REF!" when gone.33+// In a workbook of several sheets it names the sheet: "Sales!B2:B20".2734 func (n Name) Ref() string {28- if n.Lost {35+ switch {36+ case n.Gone():2937 return "#REF!"38+ case n.Sheet.wb.Len() > 1:39+ return Qualified(n.Sheet.name, n.Range)3040 }3141 return n.Range.String()3242 }
@@ -83,93 +93,105 @@ func looksLikeRef(k string) bool {
8393 }8494 8595 // Names returns the named ranges, sorted by name.86-func (s *Sheet) Names() []Name {96+func (w *Workbook) Names() []Name {8797 // The keys are already upper case: sorting names by nameKey upper-cased8898 // both sides of every comparison, 20,000 allocations a frame for 10008999 // names, since the formula bar looks for the selection's name.90- keys := slices.Sorted(maps.Keys(s.names))100+ keys := slices.Sorted(maps.Keys(w.names))91101 out := make([]Name, len(keys))92102 for i, k := range keys {93- out[i] = s.names[k]103+ out[i] = w.names[k]94104 }95105 return out96106 }97107 98108 // LookupName finds a named range, ignoring case.99-func (s *Sheet) LookupName(name string) (Name, bool) {100- n, ok := s.names[nameKey(name)]109+func (w *Workbook) LookupName(name string) (Name, bool) {110+ n, ok := w.names[nameKey(name)]101111 return n, ok102112 }103113 104114 // NameUsers returns how many formulas mention the name.105-func (s *Sheet) NameUsers(name string) int { return len(s.nameUsers[nameKey(name)]) }115+func (w *Workbook) NameUsers(name string) int { return len(w.nameUsers[nameKey(name)]) }106116 107-// DefineName names the range r, as one undo step.108-func (s *Sheet) DefineName(name string, r Rect) error {117+// DefineName names the range r on sheet s, as one undo step.118+func (w *Workbook) DefineName(name string, s *Sheet, r Rect) error {109119 if err := ValidName(name); err != nil {110120 return err111121 }112- if old, ok := s.LookupName(name); ok {122+ if old, ok := w.LookupName(name); ok {113123 return fmt.Errorf("%s already names %s", old.Name, old.Ref())114124 }115- s.change("name "+r.String()+" "+name, r, func() { s.putName(nameKey(name), &Name{Name: name, Range: r}) })125+ w.change(s, "name "+r.String()+" "+name, r, func() { w.putName(nameKey(name), &Name{Name: name, Sheet: s, Range: r}) })116126 return nil117127 }118128 119-// EditName renames the named range old and points it at r, as one undo120-// step. Formulas that use it are rewritten to the new name, as in Sheets.121-func (s *Sheet) EditName(old, name string, r Rect) error {122- cur, ok := s.LookupName(old)129+// EditName renames the named range old and points it at r on sheet s, as130+// one undo step. Formulas that use it are rewritten to the new name, as131+// in Sheets.132+func (w *Workbook) EditName(old, name string, s *Sheet, r Rect) error {133+ cur, ok := w.LookupName(old)123134 if !ok {124135 return fmt.Errorf("There's no range named %s", old)125136 }126137 if err := ValidName(name); err != nil {127138 return err128139 }129- if other, ok := s.LookupName(name); ok && nameKey(name) != nameKey(old) {140+ if other, ok := w.LookupName(name); ok && nameKey(name) != nameKey(old) {130141 return fmt.Errorf("%s already names %s", other.Name, other.Ref())131142 }132- s.change("edit "+name, r, func() {143+ w.change(s, "edit "+name, r, func() {133144 from, to := nameKey(cur.Name), nameKey(name)134145 if cur.Name != name {135- s.renameInFormulas(from, name)146+ w.renameInFormulas(from, name)136147 }137148 if from != to {138- s.putName(from, nil)149+ w.putName(from, nil)139150 }140- s.putName(to, &Name{Name: name, Range: r})151+ w.putName(to, &Name{Name: name, Sheet: s, Range: r})141152 })142153 return nil143154 }144155 145156 // DeleteName removes a named range, as one undo step. Formulas that use146157 // it show #NAME? until it is defined again.147-func (s *Sheet) DeleteName(name string) error {148- cur, ok := s.LookupName(name)158+func (w *Workbook) DeleteName(name string) error {159+ cur, ok := w.LookupName(name)149160 if !ok {150161 return fmt.Errorf("There's no range named %s", name)151162 }152- s.change("delete "+cur.Name, cur.Range, func() { s.putName(nameKey(name), nil) })163+ w.change(cur.Sheet, "delete "+cur.Name, cur.Range, func() { w.putName(nameKey(name), nil) })153164 return nil154165 }155166 167+// The sheet's name methods are the workbook's, with ranges on this sheet.168+169+func (s *Sheet) Names() []Name { return s.wb.Names() }170+func (s *Sheet) LookupName(name string) (Name, bool) { return s.wb.LookupName(name) }171+func (s *Sheet) NameUsers(name string) int { return s.wb.NameUsers(name) }172+func (s *Sheet) DefineName(name string, r Rect) error { return s.wb.DefineName(name, s, r) }173+func (s *Sheet) DeleteName(name string) error { return s.wb.DeleteName(name) }174+func (s *Sheet) EditName(old, name string, r Rect) error {175+ return s.wb.EditName(old, name, s, r)176+}177+156178 // renameInFormulas rewrites the formulas that use the name with key from157179 // to spell it to instead.158-func (s *Sheet) renameInFormulas(from, to string) {180+func (w *Workbook) renameInFormulas(from, to string) {159181 rw := refRewrite{name: func(n nameNode) Node {160182 if nameKey(n.name) == from {161183 return nameNode{to}162184 }163185 return n164186 }}165- for _, a := range slices.Collect(maps.Keys(s.nameUsers[from])) {166- s.place(a, s.cells[a].rewritten(rw))187+ for _, l := range slices.Collect(maps.Keys(w.nameUsers[from])) {188+ l.s.place(l.a, l.s.cells[l.a].rewritten(rw))167189 }168190 }169191 170192 // namePtr returns a copy of the named range with key k, or nil.171-func (s *Sheet) namePtr(k string) *Name {172- if n, ok := s.names[k]; ok {193+func (w *Workbook) namePtr(k string) *Name {194+ if n, ok := w.names[k]; ok {173195 return &n174196 }175197 return nil
@@ -178,26 +200,27 @@ func (s *Sheet) namePtr(k string) *Name {
178200 // putName stores (or with nil removes) the named range with key k,179201 // recording it for undo, and returns the formulas that use it, which need180202 // recalculating. Every change to names goes through here.181-func (s *Sheet) putName(k string, n *Name) []Addr {182- s.recordName(k)203+func (w *Workbook) putName(k string, n *Name) []loc {204+ w.recordName(k)183205 if n == nil {184- delete(s.names, k)206+ delete(w.names, k)185207 } else {186- s.names[k] = *n208+ w.names[k] = *n187209 }188- users := slices.Collect(maps.Keys(s.nameUsers[k]))189- if s.hist.open != nil {190- s.hist.dirty = append(s.hist.dirty, users...)210+ users := slices.Collect(maps.Keys(w.nameUsers[k]))211+ if w.hist.open != nil {212+ w.hist.dirty = append(w.hist.dirty, users...)191213 }192214 return users193215 }194216 195-// remapNames moves named ranges along with the cells they cover, when196-// rows or columns are inserted or deleted or cells are moved. A range197-// whose cells are all gone is lost.217+// remapNames moves the named ranges on this sheet along with the cells218+// they cover, when rows or columns are inserted or deleted or cells are219+// moved. A range whose cells are all gone is lost.198220 func (s *Sheet) remapNames(rng func(Rect) (Rect, bool)) {199- for k, n := range s.names {200- if n.Lost {221+ w := s.wb222+ for k, n := range w.names {223+ if n.Lost || n.Sheet != s {201224 continue202225 }203226 next := n
@@ -207,7 +230,7 @@ func (s *Sheet) remapNames(rng func(Rect) (Rect, bool)) {
207230 next.Range, next.Lost = Rect{}, true208231 }209232 if next != n {210- s.putName(k, &next)233+ w.putName(k, &next)211234 }212235 }213236 }
@@ -221,16 +244,21 @@ func (s *Sheet) bound(c *Cell) Node {
221244 }222245 const fixed = absCol | absRow223246 n, _ := rewrite(c.expr, refRewrite{name: func(nn nameNode) Node {224- nm, ok := s.names[nameKey(nn.name)]247+ nm, ok := s.wb.names[nameKey(nn.name)]225248 switch {226249 case !ok:227250 return nn228- case nm.Lost:251+ case nm.Gone():229252 return refErrNode{}230- case nm.Range.From == nm.Range.To:231- return refNode{nm.Range.From, fixed}232253 }233- return rangeNode{nm.Range, [2]absFlags{fixed, fixed}}254+ sheet := ""255+ if nm.Sheet != s {256+ sheet = nm.Sheet.name257+ }258+ if nm.Range.From == nm.Range.To {259+ return refNode{nm.Range.From, fixed, sheet}260+ }261+ return rangeNode{nm.Range, [2]absFlags{fixed, fixed}, sheet}234262 }})235263 return n236264 }
M internal/sheet/names_test.go +13 -3
M internal/sheet/observe.go +11 -8
@@ -7,13 +7,13 @@ import "time"
77 type RecalcInfo struct {88 Full bool // every formula, as after loading99 Evaluated int // cells marked dirty and recomputed10- Cells int // cells stored, including formatting-only ones11- Volatile int // volatile formulas, recomputed every time10+ Cells int // cells stored on every sheet, including formatting-only ones11+ Volatile int // volatile formulas on every sheet, recomputed every time1212 Circular bool1313 Duration time.Duration1414 }1515 16-// OnRecalc, when set, is called after every recalculation of any sheet.16+// OnRecalc, when set, is called after every recalculation of any workbook.1717 // The engine knows nothing of logging; cmd/012 points this at telemetry.1818 // It runs on the goroutine that changed the sheet and must be cheap.1919 var OnRecalc func(RecalcInfo)
@@ -27,12 +27,15 @@ func recalcStart() time.Time {
2727 return time.Now()2828 }2929 30-func (s *Sheet) observe(full bool, start time.Time, evaluated int) {30+func (w *Workbook) observe(full bool, start time.Time, evaluated int) {3131 if OnRecalc == nil || start.IsZero() {3232 return3333 }34- OnRecalc(RecalcInfo{35- Full: full, Evaluated: evaluated, Cells: len(s.cells), Volatile: len(s.volatile),36- Circular: s.Circular, Duration: time.Since(start),37- })34+ info := RecalcInfo{Full: full, Evaluated: evaluated, Circular: w.Circular}35+ for _, s := range w.sheets {36+ info.Cells += len(s.cells)37+ info.Volatile += len(s.volatile)38+ }39+ info.Duration = time.Since(start)40+ OnRecalc(info)3841 }
M internal/sheet/ops.go +129 -5
@@ -18,15 +18,31 @@ var (
1818 const maxFill = 1 << 201919 2020 // remap moves every stored cell with cell (dropping those it rejects) and21-// rewrites every formula with rw, as one undo step.22-func (s *Sheet) remap(label string, focus Rect, cell func(Addr) (Addr, bool), rw refRewrite) {21+// rewrites every reference to this sheet's cells, here and on other22+// sheets, with cell and rng, as one undo step. References to other sheets23+// stay as they are.24+func (s *Sheet) remap(label string, focus Rect, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) {25+ rw := relocate(s.onThis(s), cell, rng)2326 next := make(map[Addr]*Cell, len(s.cells))2427 for a, c := range s.cells {2528 if to, ok := cell(a); ok {2629 next[to] = c.rewritten(rw)2730 }2831 }32+ others := map[loc]*Cell{}33+ for _, l := range s.wb.crossList() {34+ if l.s == s {35+ continue36+ }37+ c := l.s.cells[l.a]38+ if nc := c.rewritten(relocate(s.onThis(l.s), cell, rng)); nc != c {39+ others[l] = nc40+ }41+ }2942 s.change(label, focus, func() {43+ for l, c := range others {44+ l.s.place(l.a, c)45+ }3046 for a, c := range s.cells {3147 if next[a] != c {3248 s.place(a, next[a])
@@ -99,7 +115,7 @@ func (s *Sheet) restructure(rows bool, sp span) {
99115 label += "s"100116 }101117 s.change(label, focus, func() {102- s.remap(label, focus, cell, relocate(cell, rng))118+ s.remap(label, focus, cell, rng)103119 s.remapNames(rng)104120 s.shiftCharts(cell, rng)105121 if !rows {
@@ -157,12 +173,120 @@ func (s *Sheet) Move(src Rect, to Addr) (Rect, error) {
157173 }158174 label := "move " + src.String() + " to " + dst.String()159175 s.change(label, dst, func() {160- s.remap(label, dst, cell, relocate(cell, rng))176+ s.remap(label, dst, cell, rng)161177 s.remapNames(rng) // a name for exactly the moved cells follows them162178 })163179 return dst, nil164180 }165181 182+// MoveTo moves the cells in src to sheet dst, src's top-left corner183+// landing on to, as cutting on one sheet and pasting on another does in184+// Sheets. Formulas anywhere that read the moved cells follow them to dst,185+// naming its sheet where they need to; the moved formulas keep reading186+// what they read, naming this sheet for cells that stayed behind.187+// References to cells the move overwrote become #REF!.188+func (s *Sheet) MoveTo(dst *Sheet, src Rect, to Addr) (Rect, error) {189+ if dst == s {190+ return s.Move(src, to)191+ }192+ dc, dr := to.Col-src.From.Col, to.Row-src.From.Row193+ d := Rect{to, Addr{Col: src.To.Col + dc, Row: src.To.Row + dr}}194+ if !d.To.Valid() {195+ return Rect{}, ErrPasteEdge196+ }197+ shift := func(a Addr) Addr { return Addr{Col: a.Col + dc, Row: a.Row + dr} }198+ rw := func(orig, home *Sheet) refRewrite { return s.moveRewrite(dst, src, d, orig, home) }199+200+ moved := map[Addr]*Cell{}201+ for _, a := range s.cellsIn(src) {202+ moved[shift(a)] = s.cells[a].rewritten(rw(s, dst))203+ }204+ rest := map[loc]*Cell{}205+ keep := func(l loc) {206+ if src.Contains(l.a) && l.s == s || d.Contains(l.a) && l.s == dst {207+ return208+ }209+ c := l.s.cells[l.a]210+ if nc := c.rewritten(rw(l.s, l.s)); nc != c {211+ rest[l] = nc212+ }213+ }214+ for _, t := range []*Sheet{s, dst} {215+ for a := range t.cells {216+ keep(loc{t, a})217+ }218+ }219+ for _, l := range s.wb.crossList() {220+ if l.s != s && l.s != dst {221+ keep(l)222+ }223+ }224+ label := "move " + src.String() + " to " + quoteSheet(dst.name) + "!" + d.String()225+ dst.change(label, d, func() {226+ for _, a := range s.cellsIn(src) {227+ s.place(a, nil)228+ }229+ for _, a := range dst.cellsIn(d) {230+ dst.place(a, nil)231+ }232+ for a, c := range moved {233+ dst.place(a, c)234+ }235+ for l, c := range rest {236+ l.s.place(l.a, c)237+ }238+ for k, n := range s.wb.names {239+ if n.Sheet == s && !n.Lost && src.Contains(n.Range.From) && src.Contains(n.Range.To) {240+ n.Sheet, n.Range = dst, Rect{shift(n.Range.From), shift(n.Range.To)}241+ s.wb.putName(k, &n)242+ }243+ }244+ })245+ return d, nil246+}247+248+// moveRewrite maps the references of a formula that was on orig and is249+// on home after cells in src on s move to d on dst. A reference names its250+// sheet unless it points at home.251+func (s *Sheet) moveRewrite(dst *Sheet, src, d Rect, orig, home *Sheet) refRewrite {252+ dc, dr := d.From.Col-src.From.Col, d.From.Row-src.From.Row253+ shift := func(a Addr) Addr { return Addr{Col: a.Col + dc, Row: a.Row + dr} }254+ // written is how a reference names now, having named was as written.255+ written := func(as string, was, now *Sheet) string {256+ switch {257+ case now == home:258+ return ""259+ case now == was && as != "":260+ return as261+ }262+ return now.name263+ }264+ return refRewrite{265+ ref: func(n refNode) Node {266+ t := s.wb.resolve(orig, n.sheet)267+ switch {268+ case t == nil:269+ return n270+ case t == s && src.Contains(n.a):271+ return refNode{shift(n.a), n.abs, written(n.sheet, t, dst)}272+ case t == dst && d.Contains(n.a):273+ return refErrNode{}274+ }275+ return refNode{n.a, n.abs, written(n.sheet, t, t)}276+ },277+ rng: func(n rangeNode) Node {278+ t := s.wb.resolve(orig, n.sheet)279+ switch {280+ case t == nil:281+ return n282+ case t == s && src.Contains(n.r.From) && src.Contains(n.r.To):283+ return rangeNode{Rect{shift(n.r.From), shift(n.r.To)}, n.abs, written(n.sheet, t, dst)}284+ }285+ return rangeNode{n.r, n.abs, written(n.sheet, t, t)}286+ },287+ }288+}289+166290 // Clip is a copied range: snapshots of its cells taken at copy time.167291 type Clip struct {168292 Src Rect
@@ -315,7 +439,7 @@ func (s *Sheet) FillEntry(r Rect, origin Addr, input string) error {
315439 if _, _, err := classify(input); err != nil {316440 return err317441 }318- return s.Batch(Change{"fill " + r.String(), r}, func() error {442+ return s.Batch(Change{Label: "fill " + r.String(), Focus: r}, func() error {319443 if err := s.put(origin, input); err != nil {320444 return err321445 }
M internal/sheet/parse.go +52 -13
@@ -13,15 +13,19 @@ type (
1313 numLit struct{ v float64 }1414 strLit struct{ v string }1515 boolLit struct{ v bool }16+ // refNode is a cell reference. sheet is the sheet name as written17+ // before the "!" (Sheet2!A1), or "" for the formula's own sheet.1618 refNode struct {17- a Addr18- abs absFlags19+ a Addr20+ abs absFlags21+ sheet string1922 }2023 // rangeNode is kept normalized (r.From is the top-left corner); abs2124 // holds the absolute markers of r.From and r.To.2225 rangeNode struct {23- r Rect24- abs [2]absFlags26+ r Rect27+ abs [2]absFlags28+ sheet string2529 }2630 refErrNode struct{} // a reference to deleted cells: #REF!2731 nameNode struct{ name string } // a named range as spelled in the formula; #NAME? if undefined
@@ -52,6 +56,7 @@ const (
5256 tokFunc // identifier directly followed by "("5357 tokOp // operators and punctuation5458 tokRefErr // #REF!, left behind when a referenced cell is deleted59+ tokSheet // a sheet name and its "!", as in Sheet2!A1 or 'My Sheet'!A1; text is the name5560 )5661 5762 type token struct {
@@ -122,6 +127,14 @@ func lex(src string) ([]token, error) {
122127 i++123128 }124129 toks = append(toks, token{tokStr, sb.String(), start})130+ case c == '\'':131+ // A quoted sheet name, with '' for a quote: 'Q3 ''26'!A1.132+ name, end, ok := quotedName(src, i)133+ if !ok || end >= len(src) || src[end] != '!' {134+ return nil, &ParseError{start, "Expected ! after a quoted sheet name"}135+ }136+ i = end + 1137+ toks = append(toks, token{tokSheet, name, start})125138 case c == '@' || isIdentStart(c):126139 if c == '@' { // 1-2-3 style @SUM127140 i++
@@ -129,6 +142,11 @@ func lex(src string) ([]token, error) {
129142 for i < len(src) && isIdentPart(src[i]) && !strings.HasPrefix(src[i:], "..") {130143 i++131144 }145+ if c != '@' && i < len(src) && src[i] == '!' {146+ toks = append(toks, token{tokSheet, src[start:i], start})147+ i++148+ continue149+ }132150 name := strings.ToUpper(strings.TrimPrefix(src[start:i], "@"))133151 if name == "" {134152 return nil, &ParseError{start, "Missing function name after @"}
@@ -274,7 +292,13 @@ func (p *parser) prefix() (Node, error) {
274292 case tokStr:275293 return strLit{t.text}, nil276294 case tokIdent:277- return p.ident(t)295+ return p.ident(t, "")296+ case tokSheet:297+ ref := p.next()298+ if ref.kind != tokIdent {299+ return nil, &ParseError{ref.pos, "Expected a cell after " + quoteSheet(t.text) + "!"}300+ }301+ return p.ident(ref, t.text)278302 case tokFunc:279303 return p.call(t)280304 case tokRefErr:
@@ -310,9 +334,14 @@ func (p *parser) prefix() (Node, error) {
310334 return nil, &ParseError{t.pos, "Unexpected " + t.text}311335 }312336 313-func (p *parser) ident(t token) (Node, error) {337+// ident parses a reference, a range, a boolean or a name. sheet is the338+// sheet the reference was qualified with, if any.339+func (p *parser) ident(t token, sheet string) (Node, error) {314340 a, abs, isRef := parseRef(t.text)315341 if !isRef {342+ if sheet != "" {343+ return nil, &ParseError{t.pos, "Expected a cell after " + quoteSheet(sheet) + "!"}344+ }316345 switch t.text {317346 case "TRUE":318347 return boolLit{true}, nil
@@ -325,13 +354,22 @@ func (p *parser) ident(t token) (Node, error) {
325354 if p.isOp(":") || p.isOp("..") {326355 sep := p.next()327356 end := p.next()357+ // The second corner may repeat the sheet: Sheet2!A1:Sheet2!B3.358+ if end.kind == tokSheet {359+ if sheetKey(end.text) != sheetKey(sheet) {360+ return nil, &ParseError{end.pos, "A range can't span sheets"}361+ }362+ end = p.next()363+ }328364 b, bAbs, ok := parseRef(end.text)329365 if end.kind != tokIdent || !ok {330366 return nil, &ParseError{end.pos, "Expected a cell after " + sep.text}331367 }332- return newRange(a, b, abs, bAbs), nil368+ r := newRange(a, b, abs, bAbs)369+ r.sheet = sheet370+ return r, nil333371 }334- return refNode{a, abs}, nil372+ return refNode{a, abs, sheet}, nil335373 }336374 337375 // newRange builds a normalized range from two corners as written. Each
@@ -348,7 +386,7 @@ func newRange(a, b Addr, aAbs, bAbs absFlags) rangeNode {
348386 a.Row, b.Row = b.Row, a.Row349387 swap(absRow)350388 }351- return rangeNode{Rect{a, b}, [2]absFlags{aAbs, bAbs}}389+ return rangeNode{Rect{a, b}, [2]absFlags{aAbs, bAbs}, ""}352390 }353391 354392 func (p *parser) call(t token) (Node, error) {
@@ -407,13 +445,14 @@ func walkNames(n Node, fn func(nameNode)) {
407445 }408446 }409447 410-// walkRefs calls fn for every single-cell reference and range in n.411-func walkRefs(n Node, ref func(Addr), rng func(Rect)) {448+// walkRefs calls fn for every single-cell reference and range in n, with449+// the sheet it was qualified with ("" for the formula's own sheet).450+func walkRefs(n Node, ref func(string, Addr), rng func(string, Rect)) {412451 switch n := n.(type) {413452 case refNode:414- ref(n.a)453+ ref(n.sheet, n.a)415454 case rangeNode:416- rng(n.r)455+ rng(n.sheet, n.r)417456 case unaryNode:418457 walkRefs(n.x, ref, rng)419458 case binaryNode:
M internal/sheet/print.go +9 -0
M internal/sheet/refs.go +18 -8
@@ -86,7 +86,7 @@ func shiftRefs(dc, dr int) refRewrite {
8686 if !ok {8787 return refErrNode{}8888 }89- return refNode{a, n.abs}89+ return refNode{a, n.abs, n.sheet}9090 },9191 rng: func(n rangeNode) Node {9292 from, ok1 := move(n.r.From, n.abs[0])
@@ -94,28 +94,38 @@ func shiftRefs(dc, dr int) refRewrite {
9494 if !ok1 || !ok2 {9595 return refErrNode{}9696 }97- return newRange(from, to, n.abs[0], n.abs[1])97+ r := newRange(from, to, n.abs[0], n.abs[1])98+ r.sheet = n.sheet99+ return r98100 },99101 }100102 }101103 102-// relocate is the rewrite for cells that move: cell maps where each cell103-// went (false if it's gone), and rng maps whole ranges.104-func relocate(cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) refRewrite {104+// relocate is the rewrite for cells that move on one sheet: cell maps105+// where each cell went (false if it's gone), and rng maps whole ranges.106+// on reports whether a reference written with a sheet name ("" for none)107+// points at the sheet whose cells moved; other references stay.108+func relocate(on func(sheet string) bool, cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) refRewrite {105109 return refRewrite{106110 ref: func(n refNode) Node {111+ if !on(n.sheet) {112+ return n113+ }107114 a, ok := cell(n.a)108115 if !ok {109116 return refErrNode{}110117 }111- return refNode{a, n.abs}118+ return refNode{a, n.abs, n.sheet}112119 },113120 rng: func(n rangeNode) Node {121+ if !on(n.sheet) {122+ return n123+ }114124 r, ok := rng(n.r)115125 if !ok {116126 return refErrNode{}117127 }118- return rangeNode{r, n.abs}128+ return rangeNode{r, n.abs, n.sheet}119129 },120130 }121131 }
@@ -132,7 +142,7 @@ func (c *Cell) withFormula(n Node) *Cell {
132142 // rewritten returns c with its references rewritten, or c itself if none133143 // changed.134144 func (c *Cell) rewritten(rw refRewrite) *Cell {135- if c.expr == nil || (len(c.refs) == 0 && len(c.ranges) == 0 && len(c.names) == 0) {145+ if c.expr == nil || (len(c.refs) == 0 && len(c.ranges) == 0 && len(c.names) == 0 && len(c.xrefs) == 0) {136146 return c137147 }138148 n, changed := rewrite(c.expr, rw)
M internal/sheet/refs_test.go +7 -7
M internal/sheet/remote.go +5 -4
M internal/sheet/sheet.go +159 -86
@@ -30,9 +30,10 @@ type Cell struct {
3030 3131 auto Format // format inferred from a formula, shown when Format is Automatic3232 expr Node // nil for text33- refs []Addr // single-cell references in expr34- ranges []Rect // range references in expr35- names []string // names in expr, as keys of Sheet.names33+ refs []Addr // single-cell references in expr to its own sheet34+ ranges []Rect // range references in expr to its own sheet35+ xrefs []xref // references that name a sheet, e.g. Sheet2!A136+ names []string // names in expr, as keys of Workbook.names3637 volatile bool // expr calls TODAY, NOW, RAND...3738 }3839
@@ -45,8 +46,12 @@ func (c *Cell) IsFormula() bool {
4546 // formatting).4647 func (c *Cell) Blank() bool { return c == nil || c.Input == "" }4748 48-// Sheet is a sparse worksheet.49+// Sheet is a sparse worksheet, one of a Workbook's sheets.4950 type Sheet struct {51+ wb *Workbook52+ name string53+ live bool // in the workbook's list; false once deleted54+5055 cells map[Addr]*Cell5156 widths map[int]int5257
@@ -58,15 +63,6 @@ type Sheet struct {
5863 rangeUsers rangeIndex5964 volatile map[Addr]struct{} // formulas recalculated on every change6065 61- // names are the named ranges by upper-case name (see names.go), and62- // nameUsers the formula cells that mention each name, defined or not,63- // so defining a name recalculates the formulas waiting for it.64- names map[string]Name65- nameUsers map[string]map[Addr]struct{}66-67- // Circular is set when the last recalculation found a cycle.68- Circular bool69-7066 // version counts changes to cells and their values, so what is derived7167 // from them can be cached; see RangeStats.7268 version uint64
@@ -74,22 +70,13 @@ type Sheet struct {
7470 7571 charts []Chart // floating charts, bottom first; see chart.go7672 77- hist history // undo and redo, see history.go78-7973 view viewState // frozen panes and the filter, see view.go8074 hidden hiddenCache // rows the filter hides, see filter.go8175 }8276 83-// New returns an empty worksheet.77+// New returns an empty worksheet, the only sheet of a new workbook.8478 func New() *Sheet {85- return &Sheet{86- cells: make(map[Addr]*Cell),87- widths: make(map[int]int),88- dependents: make(map[Addr]map[Addr]struct{}),89- volatile: make(map[Addr]struct{}),90- names: make(map[string]Name),91- nameUsers: make(map[string]map[Addr]struct{}),92- }79+ return NewBook().Sheet(0)9380 }9481 9582 // Cell returns the cell at a, or nil if it has neither contents nor
@@ -271,11 +258,23 @@ func formattingOnly(f Format, st Style) *Cell {
271258 272259 // setExpr sets c's expression and the references indexed from it.273260 func (c *Cell) setExpr(n Node) {274- c.expr, c.refs, c.ranges, c.names, c.volatile = n, nil, nil, nil, false261+ c.expr, c.refs, c.ranges, c.xrefs, c.names, c.volatile = n, nil, nil, nil, nil, false275262 if n != nil {276263 walkRefs(n,277- func(r Addr) { c.refs = append(c.refs, r) },278- func(r Rect) { c.ranges = append(c.ranges, r) })264+ func(sheet string, r Addr) {265+ if sheet != "" {266+ c.xrefs = append(c.xrefs, xref{sheetKey(sheet), Rect{r, r}})267+ return268+ }269+ c.refs = append(c.refs, r)270+ },271+ func(sheet string, r Rect) {272+ if sheet != "" {273+ c.xrefs = append(c.xrefs, xref{sheetKey(sheet), r})274+ return275+ }276+ c.ranges = append(c.ranges, r)277+ })279278 walkNames(n, func(nn nameNode) { c.names = append(c.names, nameKey(nn.name)) })280279 c.volatile = isVolatile(n)281280 }
@@ -302,11 +301,8 @@ func (s *Sheet) place(a Addr, c *Cell) {
302301 if c.volatile {303302 s.volatile[a] = struct{}{}304303 }305- for _, k := range c.names {306- if s.nameUsers[k] == nil {307- s.nameUsers[k] = make(map[Addr]struct{})308- }309- s.nameUsers[k][a] = struct{}{}304+ if s.live {305+ s.wb.index(loc{s, a}, c)310306 }311307 }312308
@@ -345,30 +341,44 @@ func (s *Sheet) unlink(a Addr) {
345341 delete(s.dependents, r)346342 }347343 }348- for _, k := range old.names {349- delete(s.nameUsers[k], a)350- if len(s.nameUsers[k]) == 0 {351- delete(s.nameUsers, k)352- }344+ if s.live {345+ s.wb.unindex(loc{s, a}, old)353346 }354347 s.rangeUsers.remove(a, old.ranges)355348 delete(s.volatile, a)356349 delete(s.cells, a)357350 }358351 359-// RecalcAll recomputes every formula. Loaders call it once the sheet is360-// built, so it also starts a fresh undo history: loading isn't undoable.361-func (s *Sheet) RecalcAll() {362- // Every cell is dirty, so there is nothing to propagate: tracing363- // dependents from each cell cost O(cells x range users).352+// RecalcAll recomputes every formula in the workbook. Loaders call it353+// once the sheets are built, so it also starts a fresh undo history:354+// loading isn't undoable.355+func (s *Sheet) RecalcAll() { s.wb.RecalcAll() }356+357+// RecalcAll recomputes every formula and clears the undo history.358+func (w *Workbook) RecalcAll() {359+ w.recalcAll()360+ w.ClearHistory()361+}362+363+// recalcAll recomputes every formula on every sheet, as after loading or364+// after a sheet is added, renamed or deleted, when references by name may365+// resolve differently. Every cell is dirty, so there is nothing to366+// propagate: tracing dependents from each cell cost O(cells x range367+// users).368+func (w *Workbook) recalcAll() {364369 start := recalcStart()365- state := make(map[Addr]int, len(s.cells))366- for a := range s.cells {367- state[a] = dirty370+ n := 0371+ for _, s := range w.sheets {372+ n += len(s.cells)373+ }374+ state := make(map[loc]int, n)375+ for _, s := range w.sheets {376+ for a := range s.cells {377+ state[loc{s, a}] = dirty378+ }368379 }369- s.evaluate(state)370- s.observe(true, start, len(state))371- s.ClearHistory()380+ w.evaluate(state)381+ w.observe(true, start, len(state))372382 }373383 374384 // Recalculation states of a cell.
@@ -379,64 +389,73 @@ const (
379389 )380390 381391 // recalc recomputes the changed cells, volatile formulas, and everything382-// that transitively depends on them.383-func (s *Sheet) recalc(changed []Addr) {392+// that transitively depends on them, on any sheet.393+func (w *Workbook) recalc(changed []loc) {384394 start := recalcStart()385- state := s.affected(changed)386- s.evaluate(state)387- s.observe(false, start, len(state))395+ state := w.affected(changed)396+ w.evaluate(state)397+ w.observe(false, start, len(state))388398 }389399 390400 // affected marks dirty the changed cells, volatile formulas, and every391-// formula that transitively reads them.392-func (s *Sheet) affected(changed []Addr) map[Addr]int {393- state := make(map[Addr]int)394- queue := append([]Addr(nil), changed...)395- for a := range s.volatile {396- queue = append(queue, a)401+// formula that transitively reads them, on any sheet.402+func (w *Workbook) affected(changed []loc) map[loc]int {403+ state := make(map[loc]int)404+ queue := append([]loc(nil), changed...)405+ for _, s := range w.sheets {406+ for a := range s.volatile {407+ queue = append(queue, loc{s, a})408+ }397409 }398410 // The named ranges in use, looked up once rather than for every cell.399411 type namedUsers struct {412+ s *Sheet400413 r Rect401- users map[Addr]struct{}414+ users map[loc]struct{}402415 }403416 var named []namedUsers404- for k, users := range s.nameUsers {405- if nm, ok := s.names[k]; ok && !nm.Lost {406- named = append(named, namedUsers{nm.Range, users})417+ for k, users := range w.nameUsers {418+ if nm, ok := w.names[k]; ok && !nm.Gone() {419+ named = append(named, namedUsers{nm.Sheet, nm.Range, users})407420 }408421 }409422 // Queue each formula once, not once per changed cell it reads.410- push := func(u Addr) {423+ push := func(u loc) {411424 if state[u] != dirty {412425 queue = append(queue, u)413426 }414427 }415428 for len(queue) > 0 {416- a := queue[0]429+ l := queue[0]417430 queue = queue[1:]418- if state[a] == dirty {431+ if state[l] == dirty || !l.s.live {419432 continue420433 }421- state[a] = dirty434+ state[l] = dirty435+ s, a := l.s, l.a422436 for d := range s.dependents[a] {423- push(d)437+ push(loc{s, d})424438 }425439 for u := range s.rangeUsers.candidates(a.Col) {426440 for _, r := range s.cells[u].ranges {427441 if r.Contains(a) {428- push(u)442+ push(loc{s, u})429443 break430444 }431445 }432446 }433447 for _, n := range named {434- if n.r.Contains(a) {448+ if n.s == s && n.r.Contains(a) {435449 for u := range n.users {436450 push(u)437451 }438452 }439453 }454+ for u := range w.crossUsers {455+ if w.crossReads(u, l) {456+ push(u)457+ }458+ }440459 }441460 return state442461 }
@@ -445,25 +464,27 @@ func (s *Sheet) affected(changed []Addr) map[Addr]int {
445464 // lazily in dependency order: reading a dirty cell evaluates it first. A446465 // cell that is reached again while it is still being evaluated is part of447466 // a cycle and becomes ERR.448-func (s *Sheet) evaluate(state map[Addr]int) {449- s.version++450- s.Circular = false451- s.hidden.valid = false // values may have changed what the filter hides452- var compute func(Addr) Value453- compute = func(a Addr) Value {467+func (w *Workbook) evaluate(state map[loc]int) {468+ w.Circular = false469+ for _, s := range w.sheets {470+ s.version++471+ s.hidden.valid = false // values may have changed what the filter hides472+ }473+ var compute func(loc) Value474+ compute = func(l loc) Value {454475 // Every cell a formula reads comes through here, so it looks455476 // each map up once: a SUM over 8192 cells makes 8192 calls.456- c := s.cells[a]457- switch st := state[a]; {477+ c := l.s.cells[l.a]478+ switch st := state[l]; {458479 case c == nil:459480 return Value{}460481 case st == visiting:461- s.Circular = true482+ w.Circular = true462483 return ErrRef463484 case st != dirty:464485 return c.Value465486 }466- state[a] = visiting487+ state[l] = visiting467488 c.auto = Format{}468489 switch {469490 case c.Input == "":
@@ -473,16 +494,68 @@ func (s *Sheet) evaluate(state map[Addr]int) {
473494 case c.expr == nil:474495 c.Value = Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}475496 default:476- expr := s.arith(s.bound(c))477- c.Value = eval(expr, compute)497+ expr := w.arith(l.s.bound(c))498+ // One small closure per formula, to resolve sheet names.499+ c.Value = eval(expr, w.lookupFrom(l.s, compute))478500 if _, lit := expr.(numLit); !lit {479- c.auto = inferFormat(expr, s.DisplayFormat)501+ c.auto = inferFormat(expr, w.formatFrom(l.s))480502 }481503 }482- state[a] = done504+ state[l] = done483505 return c.Value484506 }485- for a := range state {486- compute(a)507+ for l := range state {508+ compute(l)509+ }510+}511+512+// crossReads reports whether the formula at u reads the cell l through a513+// reference that names l's sheet.514+func (w *Workbook) crossReads(u, l loc) bool {515+ c := u.s.cells[u.a]516+ if c == nil {517+ return false518+ }519+ for _, x := range c.xrefs {520+ if x.r.Contains(l.a) && w.byKey[x.key] == l.s {521+ return true522+ }523+ }524+ return false525+}526+527+// lookupFrom resolves references in a formula on s with get: references528+// without a sheet read s, others the sheet they name (#REF! when no sheet529+// has that name). The last sheet name is remembered, so a range on530+// another sheet resolves its name once.531+func (w *Workbook) lookupFrom(s *Sheet, get func(loc) Value) lookup {532+ var lastName string533+ var last *Sheet534+ return func(sheet string, a Addr) Value {535+ if sheet == "" {536+ return get(loc{s, a})537+ }538+ if sheet != lastName || last == nil {539+ lastName, last = sheet, w.byKey[sheetKey(sheet)]540+ }541+ if last == nil {542+ return ErrRef543+ }544+ return get(loc{last, a})545+ }546+}547+548+// values reads current values for formulas on s, across sheets.549+func (w *Workbook) values(s *Sheet) lookup {550+ return w.lookupFrom(s, func(l loc) Value { return l.s.Value(l.a) })551+}552+553+// formatFrom reads display formats for formulas on s, across sheets.554+func (w *Workbook) formatFrom(s *Sheet) func(string, Addr) Format {555+ return func(sheet string, a Addr) Format {556+ if t := w.resolve(s, sheet); t != nil {557+ return t.DisplayFormat(a)558+ }559+ return Format{}487560 }488561 }
M internal/sheet/sheet_test.go +5 -5
M internal/sheet/trace.go +14 -4
M internal/sheet/view.go +13 -23
@@ -8,12 +8,11 @@ import (
88 )99 1010 // View state that belongs to the worksheet rather than to a cell: frozen11-// rows and columns, the filter and the arithmetic setting. It is saved in12-// the file and every change to it is an undo step, as in Sheets.11+// rows and columns, and the filter. It is saved in the file and every12+// change to it is an undo step, as in Sheets.1313 type viewState struct {1414 frozenRows, frozenCols int1515 filter *Filter // never modified in place; replaced whole16- decimal bool // decimal arithmetic, see decimal.go1716 }1817 1918 // Frozen returns how many rows and columns are frozen at the top and left.
@@ -61,16 +60,19 @@ func plural(n int, noun string) string {
6160 // recordView saves the view state before its first change in the open6261 // step.6362 func (s *Sheet) recordView() {64- st := s.hist.open65- if st == nil || st.view != nil {63+ st := s.wb.hist.open64+ if st == nil {65+ return66+ }67+ if _, seen := st.views[s]; seen {6668 return6769 }6870 v := s.view69- st.view = &v71+ st.views[s] = &v7072 }7173 7274 func (v viewState) equal(w viewState) bool {73- return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols && v.decimal == w.decimal &&75+ return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&7476 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter))7577 }7678
@@ -120,15 +122,9 @@ func (s *Sheet) shiftView(rows bool, sp span) {
120122 //121123 // "freeze": {"rows": 1},122124 // "filter": {"range": "A1:C20", "columns": {"B": {"condition": "gt", "value": "100"}}},123-//124-// and the arithmetic setting, which needs no version bump: earlier125-// builds ignore it and compute in binary, as Sheets would.126-//127-// "arithmetic": "decimal",128125 type fileView struct {129- Arithmetic string `json:"arithmetic,omitempty"`130- Freeze *fileFreeze `json:"freeze,omitempty"`131- Filter *fileFilter `json:"filter,omitempty"`126+ Freeze *fileFreeze `json:"freeze,omitempty"`127+ Filter *fileFilter `json:"filter,omitempty"`132128 }133129 134130 type fileFreeze struct {
@@ -148,12 +144,9 @@ type fileCriteria struct {
148144 }149145 150146 // writeView adds the frozen panes and the filter to a file being written.151-func (s *Sheet) writeView(b *bytes.Buffer) error {147+func (s *Sheet) writeView(b *bytes.Buffer, indent string) error {152148 var keys []string153149 var parts []any154- if s.view.decimal {155- keys, parts = append(keys, "arithmetic"), append(parts, "decimal")156- }157150 if v := s.view; v.frozenRows > 0 || v.frozenCols > 0 {158151 keys, parts = append(keys, "freeze"), append(parts, fileFreeze{Rows: v.frozenRows, Cols: v.frozenCols})159152 }
@@ -169,16 +162,13 @@ func (s *Sheet) writeView(b *bytes.Buffer) error {
169162 if err != nil {170163 return err171164 }172- fmt.Fprintf(b, " %q: %s,\n", keys[i], raw)165+ fmt.Fprintf(b, "%s%q: %s,\n", indent, keys[i], raw)173166 }174167 return nil175168 }176169 177170 // readView restores the frozen panes and the filter from a file.178171 func (s *Sheet) readView(fv fileView) error {179- // Anything but "decimal" (say, a mode from a later build) computes180- // in binary, as the file would in a build without the setting.181- s.view.decimal = fv.Arithmetic == "decimal"182172 if fz := fv.Freeze; fz != nil {183173 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)184174 }
A internal/sheet/workbook.go +495 -0
@@ -0,0 +1,495 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+)9+10+// A Workbook is an ordered list of sheets, as a Google Sheets11+// spreadsheet: each sheet has its own cells, column widths, frozen panes,12+// filter and charts, while named ranges, the undo history and13+// recalculation are shared, so a formula on one sheet can read another14+// (=Sheet2!A1, ='Q3 plan'!B2:C9) and one undo step can span sheets.15+//16+// Formulas refer to other sheets by name. Renaming a sheet rewrites the17+// formulas that name it; deleting one leaves them as written, showing18+// #REF! ("Unresolved sheet name"), until a sheet of that name exists19+// again, as in Sheets.20+type Workbook struct {21+ sheets []*Sheet22+ byKey map[string]*Sheet // live sheets by sheetKey of their name23+24+ // names are the named ranges by upper-case name (see names.go), and25+ // nameUsers the formula cells that mention each name, defined or not,26+ // so defining a name recalculates the formulas waiting for it.27+ names map[string]Name28+ nameUsers map[string]map[loc]struct{}29+30+ // crossUsers are the formulas that reference a sheet by name. Like31+ // range users, they are scanned when a cell changes; cross-sheet32+ // formulas are usually few, so this stays cheap.33+ crossUsers map[loc]struct{}34+35+ // Circular is set when the last recalculation found a cycle.36+ Circular bool37+38+ hist history // undo and redo, see history.go39+ active int // the sheet last shown, saved in the file40+ decimal bool // decimal arithmetic, see decimal.go41+42+ // structural is set when sheets were added, deleted or renamed during43+ // the open change, which then recalculates everything: references by44+ // sheet name may now resolve differently.45+ structural bool46+}47+48+// loc is a cell on a particular sheet.49+type loc struct {50+ s *Sheet51+ a Addr52+}53+54+// NewBook returns a workbook with one empty sheet, Sheet1.55+func NewBook() *Workbook {56+ w := emptyBook()57+ w.insert(w.newSheet("Sheet1"), 0)58+ return w59+}60+61+// emptyBook returns a workbook without sheets, for loaders to fill. A62+// workbook given to anyone else always has at least one sheet.63+func emptyBook() *Workbook {64+ return &Workbook{65+ byKey: map[string]*Sheet{},66+ names: map[string]Name{},67+ nameUsers: map[string]map[loc]struct{}{},68+ crossUsers: map[loc]struct{}{},69+ }70+}71+72+// newSheet makes an empty sheet belonging to w, not yet in its list.73+func (w *Workbook) newSheet(name string) *Sheet {74+ return &Sheet{75+ wb: w,76+ name: name,77+ cells: make(map[Addr]*Cell),78+ widths: make(map[int]int),79+ dependents: make(map[Addr]map[Addr]struct{}),80+ volatile: make(map[Addr]struct{}),81+ }82+}83+84+// Sheets returns the sheets in tab order.85+func (w *Workbook) Sheets() []*Sheet { return slices.Clone(w.sheets) }86+87+// Len returns the number of sheets.88+func (w *Workbook) Len() int { return len(w.sheets) }89+90+// Sheet returns the sheet at index i in tab order.91+func (w *Workbook) Sheet(i int) *Sheet { return w.sheets[i] }92+93+// Index returns s's position in tab order, or -1 if it was deleted.94+func (w *Workbook) Index(s *Sheet) int { return slices.Index(w.sheets, s) }95+96+// Lookup finds a sheet by name, ignoring case.97+func (w *Workbook) Lookup(name string) *Sheet { return w.byKey[sheetKey(name)] }98+99+// Active returns the index of the sheet last shown, as saved in the file.100+func (w *Workbook) Active() int { return clampInt(w.active, 0, len(w.sheets)-1) }101+102+// SetActive records which sheet is shown, for the file. It isn't an edit.103+func (w *Workbook) SetActive(s *Sheet) {104+ if i := w.Index(s); i >= 0 {105+ w.active = i106+ }107+}108+109+// Name returns the sheet's name.110+func (s *Sheet) Name() string { return s.name }111+112+// Book returns the workbook the sheet belongs to.113+func (s *Sheet) Book() *Workbook { return s.wb }114+115+// Live reports whether the sheet is in its workbook, i.e. not deleted.116+func (s *Sheet) Live() bool { return s.live }117+118+// maxSheetName is Excel's limit on a sheet name, kept so every workbook119+// can be downloaded as .xlsx with its names intact. Sheets allows 100.120+const maxSheetName = 31121+122+// ValidSheetName checks a name for a sheet, following Excel's rules (a123+// superset of what Sheets accepts is fine to read, but names written by124+// 012 must open in Excel too).125+func ValidSheetName(name string) error {126+ switch {127+ case strings.TrimSpace(name) == "":128+ return errors.New("Enter a sheet name")129+ case len([]rune(name)) > maxSheetName:130+ return fmt.Errorf("A sheet name can be at most %d characters", maxSheetName)131+ case strings.ContainsAny(name, `:\/?*[]`):132+ return errors.New(`A sheet name can't contain : \ / ? * [ or ]`)133+ case strings.HasPrefix(name, "'") || strings.HasSuffix(name, "'"):134+ return errors.New("A sheet name can't start or end with '")135+ case strings.ContainsFunc(name, func(r rune) bool { return r < ' ' || r == 0x7f }):136+ return errors.New("A sheet name can't contain control characters")137+ }138+ return nil139+}140+141+// sheetKey is how sheet names compare: ignoring case, as in Sheets.142+func sheetKey(name string) string { return strings.ToUpper(name) }143+144+// quoteSheet writes a sheet name as a formula needs it: bare when it reads145+// as a plain identifier (Sheet2), otherwise in single quotes with quotes146+// doubled, e.g. 'Q3 plan'.147+func quoteSheet(name string) string {148+ bare := name != "" && (isLetter(name[0]) || name[0] == '_') && !looksLikeCell(strings.ToUpper(name))149+ for i := 0; bare && i < len(name); i++ {150+ c := name[i]151+ bare = isLetter(c) || isDigit(c) || c == '_' || c == '.'152+ }153+ if bare {154+ return name155+ }156+ return "'" + strings.ReplaceAll(name, "'", "''") + "'"157+}158+159+// looksLikeCell reports whether an upper-case name reads as a cell in160+// Excel, A1 to XFD1048576 or R1C1, so a sheet of that name is quoted161+// everywhere its formulas may go. Sheet1 doesn't: SHEET is no column.162+func looksLikeCell(k string) bool {163+ digits := strings.TrimLeft(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")164+ if n := len(k) - len(digits); n >= 1 && n <= 3 && digits != "" && strings.Trim(digits, "0123456789") == "" {165+ return true166+ }167+ return (strings.HasPrefix(k, "R") || strings.HasPrefix(k, "C")) && looksLikeRef(k)168+}169+170+// QuoteSheet is quoteSheet for the UI, e.g. to show 'Q3 plan'!B2.171+func QuoteSheet(name string) string { return quoteSheet(name) }172+173+// quotedName reads a quoted sheet name starting at the quote at src[i] and174+// returns it unquoted with the index just past the closing quote.175+func quotedName(src string, i int) (string, int, bool) {176+ var b strings.Builder177+ for j := i + 1; j < len(src); j++ {178+ if src[j] != '\'' {179+ b.WriteByte(src[j])180+ continue181+ }182+ if j+1 < len(src) && src[j+1] == '\'' {183+ b.WriteByte('\'')184+ j++185+ continue186+ }187+ return b.String(), j + 1, b.Len() > 0188+ }189+ return "", len(src), false190+}191+192+// SplitSheet splits a reference such as "Sheet2!A1:B3" or "'Q3 plan'!B2"193+// into the sheet name, unquoted, and the rest. Without a sheet, sheet is194+// "" and rest is s.195+func SplitSheet(s string) (sheet, rest string) {196+ s = strings.TrimSpace(s)197+ if strings.HasPrefix(s, "'") {198+ if name, end, ok := quotedName(s, 0); ok && end < len(s) && s[end] == '!' {199+ return name, s[end+1:]200+ }201+ return "", s202+ }203+ if i := strings.LastIndexByte(s, '!'); i > 0 {204+ return s[:i], s[i+1:]205+ }206+ return "", s207+}208+209+// Qualified writes r on sheet as a formula would: Sheet2!A1:B3.210+func Qualified(sheet string, r Rect) string {211+ return quoteSheet(sheet) + "!" + r.String()212+}213+214+// nextSheetName is the first free name among Sheet1, Sheet2, ... after215+// the sheet count, as Sheets numbers new sheets.216+func (w *Workbook) nextSheetName() string {217+ for n := len(w.sheets) + 1; ; n++ {218+ if name := fmt.Sprintf("Sheet%d", n); w.Lookup(name) == nil {219+ return name220+ }221+ }222+}223+224+// freeName returns base, or base with a number added ("Copy of Sheet1 2")225+// until no sheet has the name, within the length limit.226+func (w *Workbook) freeName(base string) string {227+ trim := func(s string, n int) string {228+ if r := []rune(s); len(r) > n {229+ return string(r[:n])230+ }231+ return s232+ }233+ name := trim(base, maxSheetName)234+ for n := 2; w.Lookup(name) != nil; n++ {235+ suffix := fmt.Sprintf(" %d", n)236+ name = trim(base, maxSheetName-len(suffix)) + suffix237+ }238+ return name239+}240+241+// checkName validates a new name for s (nil for a new sheet).242+func (w *Workbook) checkName(s *Sheet, name string) error {243+ if err := ValidSheetName(name); err != nil {244+ return err245+ }246+ if other := w.Lookup(name); other != nil && other != s {247+ return fmt.Errorf("There's already a sheet named %s", other.name)248+ }249+ return nil250+}251+252+// AddSheet inserts a new, empty sheet at index at (clamped to the ends),253+// as one undo step. An empty name picks the next SheetN.254+func (w *Workbook) AddSheet(name string, at int) (*Sheet, error) {255+ if name == "" {256+ name = w.nextSheetName()257+ }258+ if err := w.checkName(nil, name); err != nil {259+ return nil, err260+ }261+ s := w.newSheet(name)262+ w.change(s, "insert sheet "+name, Rect{}, func() {263+ w.recordSheets()264+ w.insert(s, at)265+ })266+ return s, nil267+}268+269+// DuplicateSheet copies s, cells, widths, frozen panes, filter and270+// charts, to a new sheet right after it named "Copy of ...", as Sheets'271+// Duplicate. Formulas are copied as written, so references without a272+// sheet name read the copy's own cells.273+func (w *Workbook) DuplicateSheet(s *Sheet) (*Sheet, error) {274+ if !s.live {275+ return nil, errors.New("That sheet was deleted")276+ }277+ cp := w.newSheet(w.freeName("Copy of " + s.name))278+ for a, c := range s.cells {279+ c := c.clone()280+ c.setExpr(c.expr) // fresh reference slices, shared with nothing281+ cp.place(a, c)282+ }283+ for c, width := range s.widths {284+ cp.widths[c] = width285+ }286+ cp.view = s.view287+ cp.view.filter = s.view.filter.clone()288+ cp.charts = slices.Clone(s.charts)289+ w.change(cp, "duplicate "+s.name, Rect{}, func() {290+ w.recordSheets()291+ w.insert(cp, w.Index(s)+1)292+ })293+ return cp, nil294+}295+296+// DeleteSheet removes s, as one undo step. Formulas on other sheets that297+// refer to it show #REF! until it's restored or another sheet takes its298+// name. The last sheet can't be deleted.299+func (w *Workbook) DeleteSheet(s *Sheet) error {300+ i := w.Index(s)301+ switch {302+ case i < 0:303+ return errors.New("That sheet was already deleted")304+ case len(w.sheets) == 1:305+ return errors.New("A spreadsheet needs at least one sheet")306+ }307+ w.change(s, "delete sheet "+s.name, Rect{}, func() {308+ w.recordSheets()309+ w.remove(s)310+ })311+ return nil312+}313+314+// RenameSheet renames s, as one undo step, rewriting every formula that315+// refers to it by its old name.316+func (w *Workbook) RenameSheet(s *Sheet, name string) error {317+ if !s.live {318+ return errors.New("That sheet was deleted")319+ }320+ if err := w.checkName(s, name); err != nil {321+ return err322+ }323+ if name == s.name {324+ return nil325+ }326+ old := sheetKey(s.name)327+ w.change(s, "rename "+s.name+" to "+name, Rect{}, func() {328+ w.recordSheets()329+ delete(w.byKey, old)330+ s.name = name331+ w.byKey[sheetKey(name)] = s332+ if old != sheetKey(name) {333+ w.renameRefs(old, name)334+ }335+ })336+ return nil337+}338+339+// renameRefs rewrites references written with the sheet key old to name.340+func (w *Workbook) renameRefs(old, name string) {341+ rw := refRewrite{342+ ref: func(n refNode) Node {343+ if n.sheet != "" && sheetKey(n.sheet) == old {344+ n.sheet = name345+ }346+ return n347+ },348+ rng: func(n rangeNode) Node {349+ if n.sheet != "" && sheetKey(n.sheet) == old {350+ n.sheet = name351+ }352+ return n353+ },354+ }355+ for _, l := range w.crossList() {356+ if c := l.s.cells[l.a]; c.readsSheet(old) {357+ l.s.place(l.a, c.rewritten(rw))358+ }359+ }360+}361+362+// MoveSheet moves s to index to in tab order, as one undo step.363+func (w *Workbook) MoveSheet(s *Sheet, to int) {364+ i := w.Index(s)365+ to = clampInt(to, 0, len(w.sheets)-1)366+ if i < 0 || i == to {367+ return368+ }369+ w.change(s, "move sheet "+s.name, Rect{}, func() {370+ w.recordSheets()371+ order := slices.Delete(slices.Clone(w.sheets), i, i+1)372+ w.sheets = slices.Insert(order, to, s)373+ })374+}375+376+// insert puts s into the list at index at and indexes its formulas.377+func (w *Workbook) insert(s *Sheet, at int) {378+ at = clampInt(at, 0, len(w.sheets))379+ w.sheets = slices.Insert(slices.Clone(w.sheets), at, s)380+ w.attach(s)381+}382+383+// remove takes s out of the list and its formulas out of the indexes.384+func (w *Workbook) remove(s *Sheet) {385+ if i := w.Index(s); i >= 0 {386+ w.sheets = slices.Delete(slices.Clone(w.sheets), i, i+1)387+ w.detach(s)388+ }389+}390+391+// attach makes s live: findable by name, its formulas indexed for392+// recalculation across sheets.393+func (w *Workbook) attach(s *Sheet) {394+ if s.live {395+ return396+ }397+ s.live = true398+ w.byKey[sheetKey(s.name)] = s399+ for a, c := range s.cells {400+ w.index(loc{s, a}, c)401+ }402+ w.structural = true403+}404+405+// detach is the inverse of attach, for a deleted sheet. The sheet keeps406+// its cells, so undo can bring it back as it was.407+func (w *Workbook) detach(s *Sheet) {408+ if !s.live {409+ return410+ }411+ s.live = false412+ if w.byKey[sheetKey(s.name)] == s {413+ delete(w.byKey, sheetKey(s.name))414+ }415+ for a, c := range s.cells {416+ w.unindex(loc{s, a}, c)417+ }418+ w.structural = true419+}420+421+// index adds a live formula's workbook-wide dependencies: the names it422+// uses and its references to other sheets.423+func (w *Workbook) index(l loc, c *Cell) {424+ for _, k := range c.names {425+ if w.nameUsers[k] == nil {426+ w.nameUsers[k] = make(map[loc]struct{})427+ }428+ w.nameUsers[k][l] = struct{}{}429+ }430+ if len(c.xrefs) > 0 {431+ w.crossUsers[l] = struct{}{}432+ }433+}434+435+func (w *Workbook) unindex(l loc, c *Cell) {436+ for _, k := range c.names {437+ delete(w.nameUsers[k], l)438+ if len(w.nameUsers[k]) == 0 {439+ delete(w.nameUsers, k)440+ }441+ }442+ delete(w.crossUsers, l)443+}444+445+// crossList returns the cross-sheet formulas, so callers may change them446+// while going through the list.447+func (w *Workbook) crossList() []loc {448+ out := make([]loc, 0, len(w.crossUsers))449+ for l := range w.crossUsers {450+ out = append(out, l)451+ }452+ return out453+}454+455+// resolve finds the sheet a reference written with sheet points at, from456+// a formula on s: s itself for "", nil when no sheet has the name.457+func (w *Workbook) resolve(s *Sheet, sheet string) *Sheet {458+ if sheet == "" {459+ return s460+ }461+ return w.byKey[sheetKey(sheet)]462+}463+464+// onThis reports, for a formula on from, whether a reference written with465+// a sheet name ("" for none) points at s.466+func (s *Sheet) onThis(from *Sheet) func(string) bool {467+ return func(sheet string) bool { return s.wb.resolve(from, sheet) == s }468+}469+470+// xref is a reference to a range on a sheet named in the formula.471+type xref struct {472+ key string // sheetKey of the name as written473+ r Rect474+}475+476+// readsSheet reports whether the cell's formula names the sheet with key k.477+func (c *Cell) readsSheet(k string) bool {478+ if c == nil {479+ return false480+ }481+ for _, x := range c.xrefs {482+ if x.key == k {483+ return true484+ }485+ }486+ return false487+}488+489+// Batch runs fn as a single undo step across sheets, as Sheet.Batch; the490+// step is shown on c.Sheet when undone.491+func (w *Workbook) Batch(c Change, fn func() error) error {492+ var err error493+ w.change(c.Sheet, c.Label, c.Focus, func() { err = fn() })494+ return err495+}
A internal/sheet/workbook_test.go +464 -0
@@ -0,0 +1,464 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// page is one sheet of a test workbook: its name and cell inputs.10+type page struct {11+ name string12+ cells map[string]string13+}14+15+// bookOf builds a workbook from pages, without undo history.16+func bookOf(t *testing.T, pages ...page) *Workbook {17+ t.Helper()18+ w := emptyBook()19+ for _, p := range pages {20+ w.insert(w.newSheet(p.name), len(w.sheets))21+ }22+ for i, p := range pages {23+ for a, in := range p.cells {24+ if err := w.sheets[i].put(at(a), in); err != nil {25+ t.Fatalf("%s!%s: %v", p.name, a, err)26+ }27+ }28+ }29+ w.RecalcAll()30+ return w31+}32+33+// show is what a cell shows, for comparing in tests: its value, or its34+// input with "input:" in front of the address.35+func show(t *testing.T, w *Workbook, ref string) string {36+ t.Helper()37+ sheet, rest := SplitSheet(ref)38+ s := w.Lookup(sheet)39+ if s == nil {40+ t.Fatalf("no sheet %q", sheet)41+ }42+ if in, ok := strings.CutPrefix(rest, "input:"); ok {43+ if c := s.Cell(at(in)); c != nil {44+ return c.Input45+ }46+ return ""47+ }48+ return s.Value(at(rest)).String()49+}50+51+func expect(t *testing.T, w *Workbook, want map[string]string) {52+ t.Helper()53+ for ref, v := range want {54+ if got := show(t, w, ref); got != v {55+ t.Errorf("%s = %q, want %q", ref, got, v)56+ }57+ }58+}59+60+func TestParseSheetRefs(t *testing.T) {61+ tests := []struct{ in, out, err string }{62+ {in: "=Sheet2!A1", out: "=Sheet2!A1"},63+ {in: "=sheet2!$a$1+1", out: "=sheet2!$A$1+1"},64+ {in: "='My Sheet'!B2:C9", out: "='My Sheet'!B2:C9"},65+ {in: "=SUM('Bob''s'!A1:A3)", out: "=SUM('Bob''s'!A1:A3)"},66+ {in: "=Data!B3:Data!A1", out: "=Data!A1:B3"},67+ {in: "='2026'!A1", out: "='2026'!A1"},68+ {in: "='A1'!A1", out: "='A1'!A1"},69+ {in: "=Q3.plan!A1..B2", out: "=Q3.plan!A1:B2"},70+ {in: "=Sheet2!Sales", err: "Expected a cell after Sheet2!"},71+ {in: "=Sheet2!A1:Other!B2", err: "A range can't span sheets"},72+ {in: "='Open!A1", err: "Expected ! after a quoted sheet name"},73+ {in: "='x'A1", err: "Expected ! after a quoted sheet name"},74+ }75+ for _, tt := range tests {76+ n, err := Parse(tt.in)77+ switch {78+ case tt.err != "":79+ if err == nil || err.Error() != tt.err {80+ t.Errorf("%s: error %v, want %q", tt.in, err, tt.err)81+ }82+ case err != nil:83+ t.Errorf("%s: %v", tt.in, err)84+ case formulaText(n) != tt.out:85+ t.Errorf("%s prints as %s, want %s", tt.in, formulaText(n), tt.out)86+ }87+ }88+}89+90+func TestQuoteSheet(t *testing.T) {91+ for name, want := range map[string]string{92+ "Sheet1": "Sheet1", "Q3_plan": "Q3_plan", "My Sheet": "'My Sheet'", "Bob's": "'Bob''s'",93+ "2026": "'2026'", "AB12": "'AB12'", "R1C1": "'R1C1'", "Café": "'Café'",94+ } {95+ if got := quoteSheet(name); got != want {96+ t.Errorf("quoteSheet(%q) = %s, want %s", name, got, want)97+ }98+ }99+}100+101+func TestSheetNames(t *testing.T) {102+ w := NewBook()103+ tests := []struct{ name, err string }{104+ {"Budget", ""},105+ {"Sheet1", "There's already a sheet named Sheet1"},106+ {"sheet1", "There's already a sheet named Sheet1"},107+ {"a/b", `A sheet name can't contain : \ / ? * [ or ]`},108+ {"'quoted", "A sheet name can't start or end with '"},109+ {strings.Repeat("x", 32), "A sheet name can be at most 31 characters"},110+ }111+ for _, tt := range tests {112+ _, err := w.AddSheet(tt.name, w.Len())113+ if got := errText(err); got != tt.err {114+ t.Errorf("AddSheet(%q): %q, want %q", tt.name, got, tt.err)115+ }116+ }117+ if s, _ := w.AddSheet("", w.Len()); s.Name() != "Sheet3" {118+ t.Errorf("next default name %s, want Sheet3", s.Name())119+ }120+}121+122+func errText(err error) string {123+ if err == nil {124+ return ""125+ }126+ return err.Error()127+}128+129+func TestCrossSheetValues(t *testing.T) {130+ w := bookOf(t,131+ page{"Sheet1", map[string]string{132+ "A1": "=Data!A1*2", "A2": "=SUM('Q3 plan'!A1:A3)", "A3": "=Nowhere!A1",133+ "A4": "=Sales", "A5": "=Data!A1+A1", "A6": "=VLOOKUP(2, 'Q3 plan'!A1:B3, 2, FALSE)",134+ }},135+ page{"Data", map[string]string{"A1": "21", "B1": "=Sheet1!A1+1"}},136+ page{"Q3 plan", map[string]string{"A1": "1", "A2": "2", "A3": "3", "B2": "two"}},137+ )138+ if err := w.DefineName("Sales", w.Lookup("Q3 plan"), NewRect(at("A1"), at("A3"))); err != nil {139+ t.Fatal(err)140+ }141+ w.Sheet(0).Set(at("A4"), "=SUM(Sales)")142+ expect(t, w, map[string]string{143+ "Sheet1!A1": "42", "Sheet1!A2": "6", "Sheet1!A3": "#REF!", "Sheet1!A4": "6",144+ "Sheet1!A5": "63", "Sheet1!A6": "two", "Data!B1": "43",145+ })146+ if got := w.Sheet(0).ExplainError(at("A3")); got != "Unresolved sheet name Nowhere" {147+ t.Errorf("explain A3: %q", got)148+ }149+ // Changing a cell on one sheet recalculates the others, in order.150+ w.Lookup("Data").Set(at("A1"), "5")151+ w.Lookup("Q3 plan").Set(at("A3"), "30")152+ expect(t, w, map[string]string{"Sheet1!A1": "10", "Data!B1": "11", "Sheet1!A2": "33", "Sheet1!A4": "33", "Sheet1!A5": "15"})153+ // A cycle across sheets is circular.154+ w.Lookup("Data").Set(at("A1"), "=Sheet1!A1")155+ if !w.Circular || show(t, w, "Sheet1!A1") != "#REF!" {156+ t.Errorf("circular %v, A1 %s", w.Circular, show(t, w, "Sheet1!A1"))157+ }158+ if got := w.Sheet(0).ExplainError(at("A1")); got != "Circular reference: A1 → Data!A1 → A1" {159+ t.Errorf("explain cycle: %q", got)160+ }161+}162+163+func TestRenameSheetRewritesFormulas(t *testing.T) {164+ w := bookOf(t,165+ page{"Sheet1", map[string]string{"A1": "=Data!A1+data!A2", "A2": "=SUM(Data!A1:A2)", "A3": "=Other!A1"}},166+ page{"Data", map[string]string{"A1": "1", "A2": "2", "A3": "=Data!A1*10"}},167+ )168+ data := w.Lookup("Data")169+ if err := w.RenameSheet(data, "Q3 plan"); err != nil {170+ t.Fatal(err)171+ }172+ expect(t, w, map[string]string{173+ "Sheet1!input:A1": "='Q3 plan'!A1+'Q3 plan'!A2", "Sheet1!input:A2": "=SUM('Q3 plan'!A1:A2)",174+ "Sheet1!input:A3": "=Other!A1", "Q3 plan!input:A3": "='Q3 plan'!A1*10",175+ "Sheet1!A1": "3", "Q3 plan!A3": "10",176+ })177+ // Renaming to a name formulas already use makes them resolve.178+ if err := w.RenameSheet(data, "Other"); err != nil {179+ t.Fatal(err)180+ }181+ expect(t, w, map[string]string{"Sheet1!A3": "1", "Sheet1!input:A1": "=Other!A1+Other!A2"})182+ w.Undo()183+ w.Undo()184+ expect(t, w, map[string]string{"Data!A3": "10", "Sheet1!input:A1": "=Data!A1+data!A2", "Sheet1!A3": "#REF!"})185+ if data.Name() != "Data" {186+ t.Errorf("undo left the name %s", data.Name())187+ }188+}189+190+func TestDeleteSheet(t *testing.T) {191+ w := bookOf(t,192+ page{"Sheet1", map[string]string{"A1": "=Data!A1*2", "A2": "=Total"}},193+ page{"Data", map[string]string{"A1": "21"}},194+ )195+ data := w.Lookup("Data")196+ w.DefineName("Total", data, NewRect(at("A1"), at("A1")))197+ if err := w.DeleteSheet(data); err != nil {198+ t.Fatal(err)199+ }200+ expect(t, w, map[string]string{"Sheet1!A1": "#REF!", "Sheet1!A2": "#REF!", "Sheet1!input:A1": "=Data!A1*2"})201+ if n, _ := w.LookupName("Total"); n.Ref() != "#REF!" {202+ t.Errorf("name on a deleted sheet: %s", n.Ref())203+ }204+ if err := w.DeleteSheet(w.Sheet(0)); errText(err) != "A spreadsheet needs at least one sheet" {205+ t.Errorf("deleting the last sheet: %v", err)206+ }207+ // Changes to the deleted sheet's old cells don't reach anyone.208+ c, ok := w.Undo()209+ if !ok || c.Label != "delete sheet Data" || c.Sheet != data || w.Len() != 2 {210+ t.Fatalf("undo: %+v %v, %d sheets", c, ok, w.Len())211+ }212+ expect(t, w, map[string]string{"Sheet1!A1": "42", "Sheet1!A2": "21"})213+ w.Redo()214+ // A new sheet with the name brings the formulas back.215+ s, _ := w.AddSheet("data", 1)216+ s.Set(at("A1"), "4")217+ expect(t, w, map[string]string{"Sheet1!A1": "8"})218+}219+220+func TestStructureOnOneSheetLeavesOthers(t *testing.T) {221+ w := bookOf(t,222+ page{"Sheet1", map[string]string{"A1": "1", "A2": "=Data!A2", "A3": "=A1", "A4": "=SUM(Data!A1:A3)"}},223+ page{"Data", map[string]string{"A1": "10", "A2": "20", "A3": "30", "B1": "=Sheet1!A1", "B2": "=A2"}},224+ )225+ one, data := w.Sheet(0), w.Lookup("Data")226+ // Rows inserted on Data move references to Data, wherever they are.227+ if err := data.InsertRows(0, 1); err != nil {228+ t.Fatal(err)229+ }230+ expect(t, w, map[string]string{231+ "Sheet1!input:A2": "=Data!A3", "Sheet1!input:A4": "=SUM(Data!A2:A4)", "Sheet1!A2": "20",232+ "Data!input:B2": "=Sheet1!A1", "Data!input:B3": "=A3",233+ })234+ // Rows inserted on Sheet1 leave its references to Data alone.235+ if err := one.InsertRows(0, 2); err != nil {236+ t.Fatal(err)237+ }238+ expect(t, w, map[string]string{239+ "Sheet1!input:A4": "=Data!A3", "Sheet1!input:A5": "=A3", "Data!input:B2": "=Sheet1!A3", "Sheet1!A4": "20",240+ })241+ // Deleting referenced rows on Data leaves #REF! in Sheet1.242+ data.DeleteRows(2, 1)243+ expect(t, w, map[string]string{"Sheet1!input:A4": "=#REF!", "Sheet1!input:A6": "=SUM(Data!A2:A3)", "Sheet1!A6": "40"})244+ w.Undo()245+ expect(t, w, map[string]string{"Sheet1!input:A4": "=Data!A3", "Sheet1!A6": "60"})246+}247+248+func TestCopyBetweenSheets(t *testing.T) {249+ w := bookOf(t,250+ page{"Sheet1", map[string]string{"A1": "5", "B1": "=A1*2", "C1": "=Data!A1+$A$1"}},251+ page{"Data", map[string]string{"A1": "100", "A2": "7"}},252+ )253+ one, data := w.Sheet(0), w.Lookup("Data")254+ clip := one.Copy(NewRect(at("B1"), at("C1")))255+ if _, err := data.Paste(clip, NewRect(at("B2"), at("B2")), false); err != nil {256+ t.Fatal(err)257+ }258+ // References without a sheet now read Data; relative parts shifted.259+ expect(t, w, map[string]string{"Data!input:B2": "=A2*2", "Data!B2": "14", "Data!input:C2": "=Data!A2+$A$1", "Data!C2": "107"})260+}261+262+func TestMoveBetweenSheets(t *testing.T) {263+ w := bookOf(t,264+ page{"Sheet1", map[string]string{"A1": "5", "A2": "=A1*2", "A3": "=B9", "B1": "=A2+1", "C1": "=Data!Z1"}},265+ page{"Data", map[string]string{"B1": "old", "Z1": "=Sheet1!A2", "Z2": "=B1"}},266+ page{"Other", map[string]string{"A1": "=Sheet1!A1+Sheet1!A2"}},267+ )268+ one, data := w.Sheet(0), w.Lookup("Data")269+ d, err := one.MoveTo(data, NewRect(at("A1"), at("A3")), at("B1"))270+ if err != nil || d != NewRect(at("B1"), at("B3")) {271+ t.Fatalf("MoveTo: %v %v", d, err)272+ }273+ expect(t, w, map[string]string{274+ // The moved formulas read what they read, naming Sheet1 for B9.275+ "Data!input:B2": "=B1*2", "Data!input:B3": "=Sheet1!B9", "Data!B2": "10",276+ // Formulas elsewhere follow the cells to Data.277+ "Sheet1!input:B1": "=Data!B2+1", "Sheet1!B1": "11", "Data!input:Z1": "=B2",278+ "Other!input:A1": "=Data!B1+Data!B2", "Other!A1": "15",279+ // Z2 read the cell the move overwrote.280+ "Data!input:Z2": "=#REF!", "Sheet1!input:A1": "",281+ })282+ c, _ := w.Undo()283+ if c.Sheet != data {284+ t.Errorf("undo shows %v", c.Sheet)285+ }286+ expect(t, w, map[string]string{"Sheet1!input:A2": "=A1*2", "Data!B1": "old", "Other!A1": "15", "Data!input:Z2": "=B1"})287+}288+289+func TestSheetUndo(t *testing.T) {290+ w := NewBook()291+ one := w.Sheet(0)292+ one.Set(at("A1"), "=Two!A1")293+ two, _ := w.AddSheet("Two", 1)294+ two.Set(at("A1"), "7")295+ dup, _ := w.DuplicateSheet(two)296+ w.MoveSheet(dup, 0)297+ w.RenameSheet(two, "Deux")298+ order := func() string {299+ var names []string300+ for _, s := range w.Sheets() {301+ names = append(names, s.Name())302+ }303+ return strings.Join(names, ",")304+ }305+ if got := order(); got != "Copy of Two,Sheet1,Deux" || show(t, w, "Sheet1!A1") != "7" {306+ t.Fatalf("order %s, A1 %s", got, show(t, w, "Sheet1!A1"))307+ }308+ steps := []string{"Copy of Two,Sheet1,Two", "Sheet1,Two,Copy of Two", "Sheet1,Two", "Sheet1,Two", "Sheet1"}309+ for i, want := range steps {310+ w.Undo()311+ if got := order(); got != want {312+ t.Errorf("undo %d: %s, want %s", i+1, got, want)313+ }314+ }315+ if show(t, w, "Sheet1!A1") != "#REF!" {316+ t.Errorf("A1 %s after undoing the sheet", show(t, w, "Sheet1!A1"))317+ }318+ for range steps {319+ w.Redo()320+ }321+ if got := order(); got != "Copy of Two,Sheet1,Deux" || show(t, w, "Sheet1!A1") != "7" || show(t, w, "Copy of Two!A1") != "7" {322+ t.Errorf("redo: %s, A1 %s", order(), show(t, w, "Sheet1!A1"))323+ }324+}325+326+func TestDuplicateIsIndependent(t *testing.T) {327+ w := bookOf(t, page{"Sheet1", map[string]string{"A1": "2", "A2": "=A1*3"}})328+ one := w.Sheet(0)329+ one.SetFrozen(1, 0)330+ cp, err := w.DuplicateSheet(one)331+ if err != nil || cp.Name() != "Copy of Sheet1" || w.Index(cp) != 1 {332+ t.Fatalf("duplicate %v %v", cp, err)333+ }334+ cp.Set(at("A1"), "10")335+ if r, _ := cp.Frozen(); r != 1 {336+ t.Error("frozen rows not copied")337+ }338+ expect(t, w, map[string]string{"Sheet1!A2": "6", "Copy of Sheet1!A2": "30"})339+ if again, _ := w.DuplicateSheet(one); again.Name() != "Copy of Sheet1 2" {340+ t.Errorf("second copy named %s", again.Name())341+ }342+}343+344+func TestWorkbookFileRoundTrip(t *testing.T) {345+ w := bookOf(t,346+ page{"Budget", map[string]string{"A1": "Rent", "B1": "1450", "B2": "='Q3 plan'!A1"}},347+ page{"Q3 plan", map[string]string{"A1": "=Budget!B1*2"}},348+ )349+ w.Sheet(1).SetColWidth(0, 14)350+ w.Sheet(1).SetFrozen(1, 0)351+ w.DefineName("Rent", w.Sheet(0), NewRect(at("B1"), at("B1")))352+ w.SetActive(w.Sheet(1))353+ var b bytes.Buffer354+ if err := w.Write(&b); err != nil {355+ t.Fatal(err)356+ }357+ out := b.String()358+ for _, want := range []string{`"version": 4,`, `"names": {"Rent": "Budget!B1"},`, `"active": 1,`,359+ ` "name": "Q3 plan",` + "\n" + ` "widths": {"A": 14},`, ` "B2": "='Q3 plan'!A1"`} {360+ if !strings.Contains(out, want) {361+ t.Errorf("missing %s in\n%s", want, out)362+ }363+ }364+ r, err := ReadBook(strings.NewReader(out))365+ if err != nil {366+ t.Fatal(err)367+ }368+ if r.Len() != 2 || r.Active() != 1 || nameRefs(r.Sheet(0)) != "Rent=Budget!B1 " {369+ t.Fatalf("read %d sheets, active %d, names %s", r.Len(), r.Active(), nameRefs(r.Sheet(0)))370+ }371+ expect(t, r, map[string]string{"Budget!B2": "2900", "Q3 plan!A1": "2900"})372+ if rows, _ := r.Sheet(1).Frozen(); rows != 1 || r.Sheet(1).ColWidth(0) != 14 || r.CanUndo() {373+ t.Error("sheet settings not read back, or loading is undoable")374+ }375+ var again bytes.Buffer376+ r.Write(&again)377+ if again.String() != out {378+ t.Errorf("second write differs:\n%s", again.String())379+ }380+}381+382+// A workbook of one sheet keeps writing the single-sheet format, so older383+// builds open it; a renamed sheet adds a field they ignore.384+func TestSingleSheetFileStaysCompatible(t *testing.T) {385+ w := bookOf(t, page{"Sheet1", map[string]string{"A1": "1"}})386+ write := func() string {387+ var b bytes.Buffer388+ w.Write(&b)389+ return b.String()390+ }391+ if got := write(); got != "{\n \"version\": 2,\n \"cells\": {\n \"A1\": \"1\"\n }\n}\n" {392+ t.Errorf("plain sheet:\n%s", got)393+ }394+ w.RenameSheet(w.Sheet(0), "Budget")395+ out := write()396+ if !strings.HasPrefix(out, "{\n \"version\": 2,\n \"name\": \"Budget\",\n") {397+ t.Errorf("renamed sheet:\n%s", out)398+ }399+ r, err := Read(strings.NewReader(out))400+ if err != nil || r.Name() != "Budget" {401+ t.Errorf("read back %v %v", r, err)402+ }403+ // A formula naming a sheet, even an unknown one, needs version 4.404+ w.Sheet(0).Set(at("A2"), "=Other!A1")405+ if out := write(); !strings.Contains(out, `"version": 4`) {406+ t.Errorf("cross-sheet formula:\n%s", out)407+ }408+ for _, bad := range []string{409+ `{"version": 4, "sheets": []}`,410+ `{"version": 4, "sheets": [{"name": "A", "cells": {}}, {"name": "a", "cells": {}}]}`,411+ `{"version": 4, "sheets": [{"name": "x/y", "cells": {}}]}`,412+ `{"version": 4, "names": {"N": "Missing!A1"}, "sheets": [{"name": "A", "cells": {}}]}`,413+ `{"version": 5, "cells": {}}`,414+ } {415+ if _, err := ReadBook(strings.NewReader(bad)); err == nil {416+ t.Errorf("read %s without error", bad)417+ }418+ }419+}420+421+// Decimal arithmetic is a workbook setting: one undo step for every422+// sheet, saved once at the top of the file.423+func TestDecimalIsWorkbookWide(t *testing.T) {424+ w := bookOf(t,425+ page{"Sheet1", map[string]string{"A1": "=0.1+0.2"}},426+ page{"Data", map[string]string{"A1": "=Sheet1!A1=0.3"}},427+ )428+ w.SetDecimal(true)429+ expect(t, w, map[string]string{"Sheet1!A1": "0.3", "Data!A1": "TRUE"})430+ var b bytes.Buffer431+ w.Write(&b)432+ if out := b.String(); !strings.Contains(out, "{\n \"version\": 4,\n \"arithmetic\": \"decimal\",\n") {433+ t.Errorf("file:\n%s", out)434+ }435+ r, err := ReadBook(&b)436+ if err != nil || !r.Decimal() {437+ t.Fatalf("read back: decimal %v, %v", r.Decimal(), err)438+ }439+ c, _ := w.Undo()440+ if w.Decimal() || c.Label != "turn off decimal arithmetic" && c.Label != "turn on decimal arithmetic" {441+ t.Errorf("undo: %v %q", w.Decimal(), c.Label)442+ }443+ expect(t, w, map[string]string{"Data!A1": "FALSE"})444+ // A version 3 file keeps the setting at the top, as before.445+ s, err := Read(strings.NewReader(`{"version": 3, "arithmetic": "decimal", "cells": {"A1": "=0.1+0.2"}}`))446+ if err != nil || !s.Decimal() || s.Value(Addr{}).String() != "0.3" {447+ t.Errorf("version 3: %v %v", s, err)448+ }449+}450+451+func TestSplitSheet(t *testing.T) {452+ tests := []struct{ in, sheet, rest string }{453+ {"A1", "", "A1"},454+ {"Sheet2!A1:B3", "Sheet2", "A1:B3"},455+ {"'Q3 plan'!B2", "Q3 plan", "B2"},456+ {"'Bob''s'!C1", "Bob's", "C1"},457+ {" Data!Z9 ", "Data", "Z9"},458+ }459+ for _, tt := range tests {460+ if sheet, rest := SplitSheet(tt.in); sheet != tt.sheet || rest != tt.rest {461+ t.Errorf("SplitSheet(%q) = %q, %q", tt.in, sheet, rest)462+ }463+ }464+}
M internal/ui/panel.go +2 -2