cat / 012
clone

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

Tables: named ranges with a header row, read by column name in formulas

Dami committed 57cabec
Structured references (Sales[Amount], Sales[#All], Sales[@Amount],
Sales[[#Headers],[Amount]:[Units]]) parse into a TableRef the engine binds
to a range when it evaluates, so tables that grow or whose columns move
are followed without rewriting formulas. Tables live in the sheet's view,
so every change is an undo step; they grow over rows filled just below
them, rename columns in formulas when a header changes in place, move
with inserted and deleted lines and with cut and paste, and are saved in
.012 version 6. A region's table is a table by the region's name.
commit
57cabec355f409d36da890d06bf3ae23733a7902
parent
84dcbde042ed2496505781a75cdebf9c63ea8a98
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.8 of 3, Jev's estimate
30 files changed, +1870 -62
Mdocs/files/format.md21++
Minternal/formula/lex.go16+
Minternal/formula/locale.go41+++
Minternal/formula/parse.go2+
Minternal/formula/print.go2+
Minternal/formula/rewrite.go7+-
Minternal/formula/sheetname.go6+-
Ainternal/formula/structured.go373++++++++++++++++++++++++++++++
Ainternal/formula/structured_test.go113+++++++++
Minternal/sheet/evaluate.go2+-
Minternal/sheet/file.go18+-
Minternal/sheet/filestream_test.go1+
Minternal/sheet/history.go1+
Minternal/sheet/historyrestore.go7+-
Minternal/sheet/move.go2+
Minternal/sheet/names.go40+--
Minternal/sheet/recalc.go4+-
Minternal/sheet/region.go3+
Minternal/sheet/regionwrite.go6+-
Minternal/sheet/remote.go2+-
Minternal/sheet/sheet.go3+-
Minternal/sheet/sheet_test.go3+-
Ainternal/sheet/table.go362+++++++++++++++++++++++++++++
Ainternal/sheet/table_test.go271++++++++++++++++++++++
Ainternal/sheet/tablebind.go274++++++++++++++++++++++
Ainternal/sheet/tablefile.go64+++++
Ainternal/sheet/tablesync.go252++++++++++++++++++++
Minternal/sheet/trace.go10+-
Minternal/sheet/view.go21+-
Minternal/sheet/workbook.go5+-
M docs/files/format.md +21 -0
@@ -67,6 +67,7 @@ still load.
6767 | 3 | A sheet has named ranges, frozen panes or a filter | `names` (each name's range, `#REF!` once its cells were deleted), `freeze` (`rows`, `cols`), `filter` (its `range`, and `columns` by letter, each with `hidden` values or a `condition` and its `value`) |6868 | 4 | Several sheets, or a formula naming a sheet | `sheets`, a list; see [Several sheets](#several-sheets) |6969 | 5 | A pivot table | a sheet's `pivot`; see [Pivot tables](#pivot-tables) |70+| 6 | A table | a sheet's `tables`; see [Tables](#tables) |7071 7172 ## Fields that need no version7273
@@ -161,6 +162,26 @@ with its data.
161162 `condition` with its `value`.162163 - `rowTotals` and `columnTotals` are the grand total row and column.163164 165+## Tables166+167+A sheet's [tables](../sheets/tables.md) are a `tables` list among its168+view's fields, before its cells:169+170+```json171+ "tables": [{"name":"Sales","range":"A1:C20","columns":["Region","Month","Amount"],"banded":true,"header":true}],172+```173+174+- `name` is what formulas name it by (`Sales[Amount]`).175+- `range` is its cells, header row included.176+- `columns` are its columns' names, left to right, as its header row177+ shows them. Left out, or of another number than the range's columns,178+ they're read from the header row.179+- `banded` shades every other data row, and `header` styles the header180+ row.181+182+Tables need version 6: a build without them can't read the formulas183+that read them, and refuses the file rather than show them as errors.184+164185 ## Regions165186 166187 A sheet's regions, [linked files](following.md) and notebook cells'
M internal/formula/lex.go +16 -0
M internal/formula/locale.go +41 -0
@@ -98,6 +98,8 @@ func translate(src string, loc *locale.Locale, out bool) string {
9898 i = skipIdent(b, i+1)9999 case isDigit(c) || c == sep.dec && i+1 < len(b) && isDigit(b[i+1]):100100 i = sep.number(b, i)101+ case c == '[':102+ i = sep.brackets(b, i)101103 case c == '(' || c == '{':102104 open = append(open, c)103105 i++
@@ -120,6 +122,45 @@ func translate(src string, loc *locale.Locale, out bool) string {
120122 return string(b)121123 }122124 125+// brackets translates the separators between the items of a126+// structured reference, Sales[[#Headers],[Amount]], starting at the "["127+// at i, and returns where it ends. Columns' names are left alone.128+func (sep separators) brackets(b []byte, i int) int {129+ end := bracketEnd(string(b), i)130+ if end < 0 {131+ end = len(b)132+ }133+ for j := i + 1; j < end; j++ {134+ switch {135+ case b[j] == '\'':136+ j++137+ case b[j] == sep.arg && prevByte(b, j) == ']' && nextByte(b, j, end) == '[':138+ b[j] = sep.toArg139+ }140+ }141+ return end142+}143+144+// prevByte is the byte before i, past spaces, or 0.145+func prevByte(b []byte, i int) byte {146+ for i--; i >= 0 && b[i] == ' '; i-- {147+ }148+ if i < 0 {149+ return 0150+ }151+ return b[i]152+}153+154+// nextByte is the byte after i, past spaces, before end, or 0.155+func nextByte(b []byte, i, end int) byte {156+ for i++; i < end && b[i] == ' '; i++ {157+ }158+ if i >= end {159+ return 0160+ }161+ return b[i]162+}163+123164 // skipQuoted skips a string or quoted sheet name starting at i, with its124165 // quote doubled inside; an unclosed one runs to the end.125166 func skipQuoted(b []byte, i int) int {
M internal/formula/parse.go +2 -0
M internal/formula/print.go +2 -0
M internal/formula/rewrite.go +6 -1
M internal/formula/sheetname.go +3 -3
A internal/formula/structured.go +373 -0
@@ -0,0 +1,373 @@
1+package formula2+3+import (4+ "strings"5+)6+7+// Structured references name a table's cells by its columns' names, as8+// Excel's and Sheets' tables do: Sales[Amount] is the Amount column's9+// data rows, Sales[#All] the whole table, header row included,10+// Sales[@Amount] the Amount cell in the formula's own row, and11+// Sales[[#Headers],[Amount]:[Units]] the headers of three columns. The12+// parser only reads them; the engine finds the table and turns the13+// reference into a range when it evaluates the formula, so tables that14+// grow or columns that move are followed without rewriting formulas.15+16+// TableItems are the rows of a table a structured reference names.17+type TableItems uint818+19+const (20+ ItemHeaders TableItems = 1 << iota // the header row: [#Headers]21+ ItemData // the data rows: [#Data], or nothing22+ ItemTotals // a totals row: [#Totals]23+ ItemThisRow // the formula's own row: [#This Row] or @24+ // ItemAll is the whole table: [#All].25+ ItemAll = ItemHeaders | ItemData | ItemTotals26+)27+28+// itemWords are the item keywords, in the order they're printed.29+var itemWords = []struct {30+ item TableItems31+ word string32+}{{ItemAll, "#All"}, {ItemHeaders, "#Headers"}, {ItemData, "#Data"}, {ItemTotals, "#Totals"}, {ItemThisRow, "#This Row"}}33+34+// TableRef is a structured reference: Table[Column], Table[#All],35+// Table[@Column], Table[[#Headers],[First]:[Last]].36+type TableRef struct {37+ Table string // the table's name as written38+ // Items are the rows named; zero means the data rows, as when no39+ // item is written.40+ Items TableItems41+ // From and To are the first and last column named, as written: ""42+ // for every column, To "" for From alone.43+ From, To string44+}45+46+// Rows are the rows the reference names, with no item meaning the47+// data rows.48+func (t TableRef) Rows() TableItems {49+ if t.Items == 0 {50+ return ItemData51+ }52+ return t.Items53+}54+55+// Cols are the first and last column the reference names, the same56+// for one column, or "" for every column.57+func (t TableRef) Cols() (string, string) {58+ if t.To == "" {59+ return t.From, t.From60+ }61+ return t.From, t.To62+}63+64+// tableRef parses a structured reference token: the table's name and65+// its bracketed part, written as src, which starts at pos.66+func tableRef(src string, pos int) (TableRef, error) {67+ i := strings.IndexByte(src, '[')68+ t := TableRef{Table: src[:i]}69+ inner := strings.TrimSpace(src[i+1 : len(src)-1])70+ fail := func(msg string) (TableRef, error) {71+ return TableRef{}, &ParseError{Pos: pos + i, Msg: msg}72+ }73+ switch {74+ case inner == "":75+ case inner[0] == '@':76+ t.Items = ItemThisRow77+ rest := strings.TrimSpace(inner[1:])78+ if rest == "" {79+ break80+ }81+ if rest[0] != '[' {82+ t.From = unescapeColumn(rest)83+ break84+ }85+ items, from, to, msg := specList(rest)86+ if msg != "" || items != 0 {87+ return fail(cmpOr(msg, "Only columns can follow @"))88+ }89+ t.From, t.To = from, to90+ case inner[0] == '#':91+ it, ok := itemWord(inner)92+ if !ok {93+ return fail("Unknown table item " + inner + ": use #All, #Data, #Headers, #Totals or #This Row")94+ }95+ t.Items = it96+ case inner[0] == '[':97+ items, from, to, msg := specList(inner)98+ if msg != "" {99+ return fail(msg)100+ }101+ t.Items, t.From, t.To = items, from, to102+ default:103+ t.From = unescapeColumn(inner)104+ }105+ if msg := t.check(); msg != "" {106+ return fail(msg)107+ }108+ return t, nil109+}110+111+func cmpOr(a, b string) string {112+ if a != "" {113+ return a114+ }115+ return b116+}117+118+// check reports why the items can't go together, or "".119+func (t TableRef) check() string {120+ switch it := t.Items; {121+ case it&ItemThisRow != 0 && it != ItemThisRow:122+ return "#This Row can't be combined with other items"123+ case it&ItemHeaders != 0 && it&ItemTotals != 0 && it&ItemData == 0:124+ return "#Headers and #Totals need #Data between them"125+ }126+ if t.From == "" && t.To != "" {127+ return "Expected a column before :"128+ }129+ return ""130+}131+132+// itemWord reads an item keyword, ignoring case and spaces around it.133+func itemWord(s string) (TableItems, bool) {134+ s = strings.TrimSpace(s)135+ for _, w := range itemWords {136+ if strings.EqualFold(s, w.word) {137+ return w.item, true138+ }139+ }140+ return 0, false141+}142+143+// specList reads a list of bracketed items, [#Headers],[Amount]:[Units],144+// separated by , or ; and returns the items, the columns, and what's145+// wrong with it, if anything.146+func specList(s string) (items TableItems, from, to, msg string) {147+ cols := 0148+ for i := 0; ; {149+ i = skipSpaces(s, i)150+ text, next, ok := bracketed(s, i)151+ if !ok {152+ return 0, "", "", "Expected [ and ] around each item"153+ }154+ if strings.HasPrefix(strings.TrimSpace(text), "#") {155+ it, ok := itemWord(text)156+ if !ok {157+ return 0, "", "", "Unknown table item " + strings.TrimSpace(text) + ": use #All, #Data, #Headers, #Totals or #This Row"158+ }159+ items |= it160+ } else {161+ if cols++; cols > 1 {162+ return 0, "", "", "Name one column, or a range of them as [First]:[Last]"163+ }164+ from = unescapeColumn(text)165+ if j := skipSpaces(s, next); j < len(s) && s[j] == ':' {166+ var last string167+ if last, next, ok = bracketed(s, skipSpaces(s, j+1)); !ok {168+ return 0, "", "", "Expected [column] after :"169+ }170+ to = unescapeColumn(last)171+ }172+ }173+ i = skipSpaces(s, next)174+ if i >= len(s) {175+ return items, from, to, ""176+ }177+ if s[i] != ',' && s[i] != ';' {178+ return 0, "", "", "Expected , between a table's items"179+ }180+ i++181+ }182+}183+184+// bracketed reads [text] at s[i], with ' escaping the character after185+// it, and returns the text as written and the index past the "]".186+func bracketed(s string, i int) (string, int, bool) {187+ if i >= len(s) || s[i] != '[' {188+ return "", i, false189+ }190+ for j := i + 1; j < len(s); j++ {191+ switch s[j] {192+ case '\'':193+ j++194+ case '[':195+ return "", j, false196+ case ']':197+ return s[i+1 : j], j + 1, true198+ }199+ }200+ return "", len(s), false201+}202+203+func skipSpaces(s string, i int) int {204+ for i < len(s) && s[i] == ' ' {205+ i++206+ }207+ return i208+}209+210+// bracketEnd returns the index just past the "]" closing the "[" at211+// src[i], or -1: brackets nest, and ' escapes the character after it.212+func bracketEnd(src string, i int) int {213+ depth := 0214+ for j := i; j < len(src); j++ {215+ switch src[j] {216+ case '\'':217+ j++218+ case '[':219+ depth++220+ case ']':221+ if depth--; depth == 0 {222+ return j + 1223+ }224+ }225+ }226+ return -1227+}228+229+// escaped are the characters a column's name escapes with ' in a230+// structured reference.231+const escaped = "[]#'"232+233+// unescapeColumn reads a column's name as written, trimmed, with its234+// escapes removed.235+func unescapeColumn(s string) string {236+ s = strings.TrimSpace(s)237+ if !strings.Contains(s, "'") {238+ return s239+ }240+ var b strings.Builder241+ for i := 0; i < len(s); i++ {242+ if s[i] == '\'' && i+1 < len(s) {243+ i++244+ }245+ b.WriteByte(s[i])246+ }247+ return b.String()248+}249+250+// EscapeColumn writes a column's name as a structured reference holds251+// it, with ' before each of [ ] # and '.252+func EscapeColumn(name string) string {253+ if !strings.ContainsAny(name, escaped) {254+ return name255+ }256+ var b strings.Builder257+ for i := 0; i < len(name); i++ {258+ if strings.IndexByte(escaped, name[i]) >= 0 {259+ b.WriteByte('\'')260+ }261+ b.WriteByte(name[i])262+ }263+ return b.String()264+}265+266+// plainColumn reports whether a column's name can stand alone in the267+// brackets, Sales[Unit Price], rather than in brackets of its own,268+// Sales[[Total, EUR]]: nothing but letters, digits, _, spaces inside269+// and characters beyond ASCII.270+func plainColumn(name string) bool {271+ if name == "" || name[0] == ' ' || name[len(name)-1] == ' ' {272+ return false273+ }274+ for i := 0; i < len(name); i++ {275+ if c := name[i]; !isLetter(c) && !isDigit(c) && c != '_' && c != ' ' && c < 0x80 {276+ return false277+ }278+ }279+ return true280+}281+282+// String writes the reference as 012 prints it: Sales[Amount],283+// Sales[@Amount], Sales[[#Headers],[Amount]].284+func (t TableRef) String() string { return t.write(false) }285+286+// Excel writes the reference as Excel's files hold it, where the287+// formula's own row is [#This Row] rather than @.288+func (t TableRef) Excel() string { return t.write(true) }289+290+func (t TableRef) write(excel bool) string {291+ var b strings.Builder292+ b.WriteString(t.Table)293+ b.WriteByte('[')294+ cols := t.columns()295+ switch {296+ case t.Items == ItemThisRow && !excel:297+ b.WriteByte('@')298+ if cols != "" && t.To == "" && plainColumn(t.From) && !strings.Contains(t.From, " ") {299+ b.WriteString(t.From)300+ } else {301+ b.WriteString(cols)302+ }303+ case t.Items == 0 && cols == "":304+ case t.Items == 0 && t.To == "" && plainColumn(t.From):305+ b.WriteString(t.From)306+ case t.Items == 0:307+ b.WriteString(cols)308+ case cols == "" && t.single():309+ b.WriteString(t.words()[0])310+ default:311+ for i, w := range t.words() {312+ if i > 0 {313+ b.WriteByte(',')314+ }315+ b.WriteString("[" + w + "]")316+ }317+ if cols != "" {318+ b.WriteString("," + cols)319+ }320+ }321+ b.WriteByte(']')322+ return b.String()323+}324+325+// columns writes the columns named in brackets, [From] or326+// [From]:[To], or "" for every column.327+func (t TableRef) columns() string {328+ if t.From == "" {329+ return ""330+ }331+ s := "[" + EscapeColumn(t.From) + "]"332+ if t.To != "" {333+ s += ":[" + EscapeColumn(t.To) + "]"334+ }335+ return s336+}337+338+// single reports whether the reference names one item keyword.339+func (t TableRef) single() bool { return len(t.words()) == 1 }340+341+// words are the item keywords the reference names.342+func (t TableRef) words() []string {343+ var out []string344+ left := t.Items345+ for _, w := range itemWords {346+ if left&w.item == w.item && w.item != 0 {347+ out = append(out, w.word)348+ left &^= w.item349+ }350+ }351+ return out352+}353+354+// ExcelTableRef writes a structured reference, the table's name and355+// its brackets as written in src, in Excel's file syntax, or reports356+// false when it doesn't parse.357+func ExcelTableRef(src string) (string, bool) {358+ t, err := tableRef(src, 0)359+ if err != nil {360+ return "", false361+ }362+ return t.Excel(), true363+}364+365+// WalkTables calls fn for every structured reference in n.366+func WalkTables(n Node, fn func(TableRef)) {367+ switch n := n.(type) {368+ case TableRef:369+ fn(n)370+ case Unary, Binary, Call, Array, Invoke:371+ EachChild(n, func(k Node) { WalkTables(k, fn) })372+ }373+}
A internal/formula/structured_test.go +113 -0
@@ -0,0 +1,113 @@
1+package formula2+3+import (4+ "strings"5+ "testing"6+)7+8+// Structured references parse into what they name and print back in9+// 012's spelling; Excel's file form spells the formula's row #This Row.10+func TestTableRefParse(t *testing.T) {11+ cases := []struct {12+ in string13+ want TableRef14+ prints string15+ excel string16+ }{17+ {"Sales[Amount]", TableRef{Table: "Sales", From: "Amount"}, "Sales[Amount]", "Sales[Amount]"},18+ {"sales[ Amount ]", TableRef{Table: "sales", From: "Amount"}, "sales[Amount]", "sales[Amount]"},19+ {"Sales[]", TableRef{Table: "Sales"}, "Sales[]", "Sales[]"},20+ {"Sales[#All]", TableRef{Table: "Sales", Items: ItemAll}, "Sales[#All]", "Sales[#All]"},21+ {"Sales[#headers]", TableRef{Table: "Sales", Items: ItemHeaders}, "Sales[#Headers]", "Sales[#Headers]"},22+ {"Sales[#This Row]", TableRef{Table: "Sales", Items: ItemThisRow}, "Sales[@]", "Sales[#This Row]"},23+ {"Sales[@Amount]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Amount"}, "Sales[@Amount]", "Sales[[#This Row],[Amount]]"},24+ {"Sales[@[Unit Price]]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Unit Price"}, "Sales[@[Unit Price]]", "Sales[[#This Row],[Unit Price]]"},25+ {"Sales[[#This Row],[Amount]]", TableRef{Table: "Sales", Items: ItemThisRow, From: "Amount"}, "Sales[@Amount]", "Sales[[#This Row],[Amount]]"},26+ {"Sales[[#Headers],[Amount]]", TableRef{Table: "Sales", Items: ItemHeaders, From: "Amount"}, "Sales[[#Headers],[Amount]]", "Sales[[#Headers],[Amount]]"},27+ {"Sales[[#Headers];[#Data];[Amount]:[Units]]", TableRef{Table: "Sales", Items: ItemHeaders | ItemData, From: "Amount", To: "Units"}, "Sales[[#Headers],[#Data],[Amount]:[Units]]", "Sales[[#Headers],[#Data],[Amount]:[Units]]"},28+ {"Sales[[Amount]:[Units]]", TableRef{Table: "Sales", From: "Amount", To: "Units"}, "Sales[[Amount]:[Units]]", "Sales[[Amount]:[Units]]"},29+ {"Sales[[Total, EUR]]", TableRef{Table: "Sales", From: "Total, EUR"}, "Sales[[Total, EUR]]", "Sales[[Total, EUR]]"},30+ {"Sales['#Items]", TableRef{Table: "Sales", From: "#Items"}, "Sales[['#Items]]", "Sales[['#Items]]"},31+ {"Sales[[Q'[1']]]", TableRef{Table: "Sales", From: "Q[1]"}, "Sales[[Q'[1']]]", "Sales[[Q'[1']]]"},32+ {"Sales[Größe]", TableRef{Table: "Sales", From: "Größe"}, "Sales[Größe]", "Sales[Größe]"},33+ {"app[status]", TableRef{Table: "app", From: "status"}, "app[status]", "app[status]"},34+ }35+ for _, c := range cases {36+ n, err := Parse("="+c.in, testFuncs)37+ if err != nil {38+ t.Errorf("%s: %v", c.in, err)39+ continue40+ }41+ got, ok := n.(TableRef)42+ if !ok || got != c.want {43+ t.Errorf("%s = %#v, want %#v", c.in, n, c.want)44+ continue45+ }46+ if p := Text(n); p != "="+c.prints {47+ t.Errorf("%s prints %s, want %s", c.in, p, c.prints)48+ }49+ if back, err := Parse(Text(n), testFuncs); err != nil || back != n {50+ t.Errorf("%s: printed %s parses as %#v, %v", c.in, Text(n), back, err)51+ }52+ if x := got.Excel(); x != c.excel {53+ t.Errorf("%s in Excel's form is %s, want %s", c.in, x, c.excel)54+ }55+ }56+}57+58+// Structured references take part in expressions like any reference.59+func TestTableRefInFormulas(t *testing.T) {60+ n, err := Parse("=SUM(Sales[Amount])*Sales[@Qty]+nu.app", testFuncs)61+ if err != nil {62+ t.Fatal(err)63+ }64+ var tables []string65+ WalkTables(n, func(r TableRef) { tables = append(tables, r.String()) })66+ if strings.Join(tables, " ") != "Sales[Amount] Sales[@Qty]" {67+ t.Errorf("walked %v", tables)68+ }69+ out, changed := Rewrite(n, Rewriter{Table: func(r TableRef) Node {70+ r.Table = "Revenue"71+ return r72+ }})73+ if !changed || Text(out) != "=SUM(Revenue[Amount])*Revenue[@Qty]+nu.app" {74+ t.Errorf("renamed to %s", Text(out))75+ }76+}77+78+func TestTableRefErrors(t *testing.T) {79+ for in, msg := range map[string]string{80+ "=Sales[Amount": "Missing ]",81+ "=Sales[#Everything]": "Unknown table item #Everything",82+ "=Sales[[#Nope],[A]]": "Unknown table item #Nope",83+ "=Sales[[#This Row],[#Data]]": "#This Row can't be combined",84+ "=Sales[[#Headers],[#Totals]]": "#Headers and #Totals need #Data",85+ "=Sales[[A],[B]]": "Name one column",86+ "=Sales[[A] [B]]": "Expected , between",87+ "=Sales[[A]:B]": "Expected [column] after :",88+ "=Sales[@[#Headers]]": "Only columns can follow @",89+ "=Sales[[A]]x": "Unexpected",90+ "=Sales[[A]]]": "Unexpected",91+ "=SUM(Sales[[#Data],[A]:[B]]": "Expected , or )",92+ "=Sales[Amount]:Sales[Units]+1": "Unexpected",93+ } {94+ _, err := Parse(in, testFuncs)95+ if err == nil || !strings.Contains(err.Error(), msg) {96+ t.Errorf("%s: %v, want %q", in, err, msg)97+ }98+ }99+}100+101+// Separators between a reference's items follow the locale; names in102+// brackets are left as they are.103+func TestTableRefLocale(t *testing.T) {104+ de := mustLocale(t, "de-DE")105+ typed := "=SUMME(Sales[[#Headers];[Total, EUR]];1,5)"106+ stored := Delocalize(typed, de)107+ if stored != "=SUMME(Sales[[#Headers],[Total, EUR]],1.5)" {108+ t.Errorf("stored %s", stored)109+ }110+ if back := Localize(stored, de); back != typed {111+ t.Errorf("shown %s", back)112+ }113+}
M internal/sheet/evaluate.go +1 -1
M internal/sheet/file.go +11 -7
@@ -22,8 +22,9 @@ const FileExt = ".012"
2222 // name no sheet is still written as version 2 or 3, its sheet's name in2323 // a "name" field that earlier builds ignore.2424 // Version 5 is version 4 with pivot tables; a workbook without one is25-// still written as version 4.26-const fileVersion = 525+// still written as version 4. Version 6 is version 5 with tables26+// (tablefile.go), written only for a workbook with one.27+const fileVersion = 62728 2829 // The file is JSON with one entry per cell, keyed by address. A cell2930 // without formatting is just its input, as in version 1; a formatted cell
@@ -112,8 +113,11 @@ func (w *Workbook) Write(out io.Writer) error {
112113 b.WriteString("\n}\n")113114 } else {114115 version := 4115- if w.hasPivots() {116- version = fileVersion116+ switch {117+ case w.hasTables():118+ version = 6119+ case w.hasPivots():120+ version = 5117121 }118122 fmt.Fprintf(b, "{\n \"version\": %d,\n", version)119123 if head := w.headLines(); head != "" {
@@ -142,10 +146,10 @@ func (w *Workbook) Write(out io.Writer) error {
142146 }143147 144148 // single reports whether the workbook fits the single-sheet format of145-// versions 2 and 3: one sheet, not a notebook, and no formula naming a146-// sheet.149+// versions 2 and 3: one sheet, not a notebook, no pivot table nor table,150+// and no formula naming a sheet.147151 func (w *Workbook) single() bool {148- return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook()152+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook() && !w.hasTables()149153 }150154 151155 func jsonString(s string) string {
M internal/sheet/filestream_test.go +1 -0
M internal/sheet/history.go +1 -0
M internal/sheet/historyrestore.go +5 -2
M internal/sheet/move.go +2 -0
M internal/sheet/names.go +9 -31
@@ -51,7 +51,11 @@ func nameKey(name string) string { return strings.ToUpper(name) }
5151 // ValidName checks that name can be a named range, following Sheets'5252 // rules: letters, digits, _ and ., starting with a letter or _, and not5353 // something a formula would read as a cell or a boolean.54-func ValidName(name string) error {54+func ValidName(name string) error { return checkName(name, formula.LooksLikeRef) }55+56+// checkName checks name against the rules of names, isRef telling57+// which read as a cell reference.58+func checkName(name string, isRef func(string) bool) error {5559 switch {5660 case name == "":5761 return errors.New("Enter a name")
@@ -68,7 +72,7 @@ func ValidName(name string) error {
6872 switch k := nameKey(name); {6973 case k == "TRUE" || k == "FALSE":7074 return errors.New("TRUE and FALSE can't be names")71- case formula.LooksLikeRef(k):75+ case isRef(k):7276 return errors.New("A name can't look like a cell reference, e.g. A1 or R1C1")7377 }7478 return nil
@@ -107,6 +111,9 @@ func (w *Workbook) DefineName(name string, s *Sheet, r Rect) error {
107111 if _, _, ok := w.regionName(nameKey(name)); ok {108112 return fmt.Errorf("%s names a shell region", name)109113 }114+ if _, t, ok := w.Table(name); ok {115+ return fmt.Errorf("%s names a table", t.Name)116+ }110117 w.change(s, "name "+r.String()+" "+name, r, func() { w.putName(nameKey(name), &Name{Name: name, Sheet: s, Range: r}) })111118 return nil112119 }
@@ -219,32 +226,3 @@ func (s *Sheet) remapNames(rng func(Rect) (Rect, bool)) {
219226 }220227 }221228 }222-223-// bound returns the cell's formula with its names replaced by the ranges224-// they stand for: what is evaluated and what dependencies are traced225-// through. Undefined names stay, and evaluate to #NAME?.226-func (s *Sheet) bound(c *Cell) Node {227- if len(c.names) == 0 {228- return c.expr229- }230- const fixed = formula.AbsCol | formula.AbsRow231- n, _ := formula.Rewrite(c.expr, formula.Rewriter{Name: func(nn formula.Name) Node {232- nm, ok := s.wb.names[nameKey(nn.Name)]233- if !ok {234- return s.boundRegion(nn)235- }236- switch {237- case nm.Gone():238- return formula.RefErr{}239- }240- sheet := ""241- if nm.Sheet != s {242- sheet = nm.Sheet.name243- }244- if nm.Range.From == nm.Range.To {245- return formula.Ref{Addr: nm.Range.From, Abs: fixed, Sheet: sheet}246- }247- return formula.Range{Rect: nm.Range, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}248- }})249- return n250-}
M internal/sheet/recalc.go +3 -1
M internal/sheet/region.go +3 -0
M internal/sheet/regionwrite.go +4 -2
M internal/sheet/remote.go +1 -1
M internal/sheet/sheet.go +2 -1
M internal/sheet/sheet_test.go +2 -1
A internal/sheet/table.go +362 -0
@@ -0,0 +1,362 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/formula"11+)12+13+// Tables, as Sheets' Format > Convert to table and Excel's tables: a14+// named range whose first row names its columns, so formulas read it by15+// column name (Sales[Amount], see tablebind.go). A table grows as rows16+// are typed or pasted just below it, and its columns' names follow its17+// header row: renaming a header renames the column in every formula18+// that reads it (tablesync.go). Tables belong to the sheet's view state,19+// so each change to one is an undo step with the rest of the view.20+//21+// A region's table (a notebook cell's output sent to a sheet, or a22+// linked file) is a table too, by the region's name: app[status] reads23+// its status column. Its rows and columns are its source's, so the24+// commands that rename, resize, style or remove a table leave it alone.25+26+// Table is a table on a sheet.27+type Table struct {28+ Name string // as the user spelled it; formulas match it in any case29+ Range Rect // header row included30+ // Cols are the columns' names, left to right: the header row's text31+ // as last settled, never blank and never the same twice.32+ Cols []string33+ // Banded shades every other data row; Header styles the header row.34+ Banded, Header bool35+}36+37+// equal reports whether two tables are the same.38+func (t Table) equal(u Table) bool {39+ return t.Name == u.Name && t.Range == u.Range && slices.Equal(t.Cols, u.Cols) && t.Banded == u.Banded && t.Header == u.Header40+}41+42+// clone is a copy of t sharing nothing with it.43+func (t Table) clone() Table {44+ t.Cols = slices.Clone(t.Cols)45+ return t46+}47+48+// Data is the table's data rows, below the header row.49+func (t Table) Data() Rect {50+ d := t.Range51+ d.From.Row++52+ return d53+}54+55+// Col returns the index of the column named name, ignoring case, or -1.56+func (t Table) Col(name string) int {57+ return slices.IndexFunc(t.Cols, func(c string) bool { return strings.EqualFold(c, name) })58+}59+60+// Errors of tables.61+var (62+ ErrNoTable = errors.New("There's no table by that name")63+ ErrTableOverlap = errors.New("A table can't overlap another table, a region or a pivot table")64+ ErrTableMerged = errors.New("A table can't hold merged cells: unmerge them first")65+)66+67+// tablePrefix is reserved for regions' names in formulas (nu.sales).68+const tablePrefix = regionPrefix69+70+// ValidTableName checks that name can name a table: a named range's71+// rules, except that only a name Excel reads as a cell is refused, so72+// Table1 is one; and not starting with nu., which regions' names use.73+func ValidTableName(name string) error {74+ if err := checkName(name, formula.LooksLikeCell); err != nil {75+ return err76+ }77+ if strings.HasPrefix(nameKey(name), nameKey(tablePrefix)) {78+ return errors.New("A table's name can't start with " + tablePrefix)79+ }80+ return nil81+}82+83+// checkTableName reports why name can't name a table, besides the one84+// with key self (when renaming one).85+func (w *Workbook) checkTableName(name, self string) error {86+ if err := ValidTableName(name); err != nil {87+ return err88+ }89+ k := nameKey(name)90+ if n, ok := w.LookupName(name); ok {91+ return fmt.Errorf("%s already names %s", n.Name, n.Ref())92+ }93+ if _, t, ok := w.Table(name); ok && k != self {94+ return fmt.Errorf("There's already a table named %s", t.Name)95+ }96+ if _, r, ok := w.Region(name); ok {97+ return fmt.Errorf("%s names a notebook output or a linked file", r.Name)98+ }99+ return nil100+}101+102+// Tables returns the sheet's tables, in the order they were made.103+func (s *Sheet) Tables() []Table {104+ out := make([]Table, len(s.view.tables))105+ for i, t := range s.view.tables {106+ out[i] = t.clone()107+ }108+ return out109+}110+111+// HasTables reports whether the sheet has tables, so what draws it can112+// skip looking for them.113+func (s *Sheet) HasTables() bool { return len(s.view.tables) > 0 }114+115+// Table finds a table by name, ignoring case, on any sheet. Regions'116+// tables aren't among them: see TableInfo.117+func (w *Workbook) Table(name string) (*Sheet, Table, bool) {118+ k := nameKey(name)119+ for _, s := range w.sheets {120+ if i := s.tableIndex(k); i >= 0 {121+ return s, s.view.tables[i].clone(), true122+ }123+ }124+ return nil, Table{}, false125+}126+127+func (s *Sheet) tableIndex(k string) int {128+ return slices.IndexFunc(s.view.tables, func(t Table) bool { return nameKey(t.Name) == k })129+}130+131+// TableAt returns the table holding the cell at a.132+func (s *Sheet) TableAt(a Addr) (Table, bool) {133+ for _, t := range s.view.tables {134+ if t.Range.Contains(a) {135+ return t.clone(), true136+ }137+ }138+ return Table{}, false139+}140+141+// NextTableName is a name no table has yet: Table1, Table2 ...142+func (w *Workbook) NextTableName() string {143+ for i := 1; ; i++ {144+ name := "Table" + strconv.Itoa(i)145+ if w.checkTableName(name, "") == nil {146+ return name147+ }148+ }149+}150+151+// copiedTables are ts as a copied sheet holds them, each renamed to a152+// name no table has: Sales_2, Sales_3 ...153+func (w *Workbook) copiedTables(ts []Table) []Table {154+ out := make([]Table, len(ts))155+ taken := map[string]bool{}156+ for i, t := range ts {157+ t = t.clone()158+ for n := 2; ; n++ {159+ name := t.Name + "_" + strconv.Itoa(n)160+ if !taken[nameKey(name)] && w.checkTableName(name, "") == nil {161+ t.Name, taken[nameKey(name)] = name, true162+ break163+ }164+ }165+ out[i] = t166+ }167+ return out168+}169+170+// checkTableRange reports why r can't hold the table with key self:171+// it overlaps another table, a region or a pivot table, or holds172+// merged cells.173+func (s *Sheet) checkTableRange(r Rect, self string) error {174+ for _, t := range s.view.tables {175+ if nameKey(t.Name) != self && overlaps(t.Range, r) {176+ return ErrTableOverlap177+ }178+ }179+ if _, _, ok := s.InRegion(r); ok || s.InPivot(r) {180+ return ErrTableOverlap181+ }182+ if len(s.MergesIn(r)) > 0 {183+ return ErrTableMerged184+ }185+ return nil186+}187+188+// tableRange is r as a table's range: a header row and at least one189+// row of data.190+func tableRange(r Rect) Rect {191+ if r.To.Row == r.From.Row && r.To.Row < MaxRows-1 {192+ r.To.Row++193+ }194+ return r195+}196+197+// CreateTable makes a table named name of r, whose first row names its198+// columns, as one undo step. Blank or repeated names in the header row199+// are replaced with ones of their own (Column2, Amount2), in the cells200+// too; a range of one row gains an empty row of data.201+func (s *Sheet) CreateTable(name string, r Rect) error {202+ if err := s.wb.checkTableName(name, ""); err != nil {203+ return err204+ }205+ r = tableRange(r)206+ if r.To.Row == r.From.Row {207+ return errors.New("A table needs a row below its header row")208+ }209+ if err := s.checkTableRange(r, ""); err != nil {210+ return err211+ }212+ s.change("make table "+name, r, func() {213+ t := Table{Name: name, Range: r, Banded: true, Header: true}214+ t.Cols = s.headerNames(t)215+ s.putTables(append(s.Tables(), t))216+ })217+ return nil218+}219+220+// RenameTable renames a table, as one undo step, and every formula221+// that reads it with it.222+func (w *Workbook) RenameTable(old, name string) error {223+ s, t, ok := w.Table(old)224+ if !ok {225+ return ErrNoTable226+ }227+ if err := w.checkTableName(name, nameKey(old)); err != nil {228+ return err229+ }230+ if t.Name == name {231+ return nil232+ }233+ s.change("rename table "+t.Name, t.Range, func() {234+ w.renameTableInFormulas(nameKey(old), name)235+ s.editTable(nameKey(old), func(t *Table) { t.Name = name })236+ })237+ return nil238+}239+240+// ResizeTable points a table at r, as one undo step. Its columns take241+// their names from r's first row.242+func (w *Workbook) ResizeTable(name string, r Rect) error {243+ s, t, ok := w.Table(name)244+ if !ok {245+ return ErrNoTable246+ }247+ r = tableRange(r)248+ if err := s.checkTableRange(r, nameKey(name)); err != nil {249+ return err250+ }251+ s.change("resize table "+t.Name, r, func() {252+ s.editTable(nameKey(name), func(t *Table) {253+ t.Range = r254+ t.Cols = s.headerNames(*t)255+ })256+ })257+ return nil258+}259+260+// SetTableStyle sets whether a table's data rows are banded and its261+// header row styled, as one undo step.262+func (w *Workbook) SetTableStyle(name string, banded, header bool) error {263+ s, t, ok := w.Table(name)264+ if !ok {265+ return ErrNoTable266+ }267+ s.change("style table "+t.Name, t.Range, func() {268+ s.editTable(nameKey(name), func(t *Table) { t.Banded, t.Header = banded, header })269+ })270+ return nil271+}272+273+// RemoveTable removes a table, keeping its cells, as one undo step.274+// Formulas that read it read the same cells by address instead, as275+// Excel's Convert to range leaves them.276+func (w *Workbook) RemoveTable(name string) error {277+ s, t, ok := w.Table(name)278+ if !ok {279+ return ErrNoTable280+ }281+ s.change("remove table "+t.Name, t.Range, func() {282+ w.unbindTable(nameKey(name))283+ st := s.Tables()284+ st = slices.DeleteFunc(st, func(u Table) bool { return nameKey(u.Name) == nameKey(name) })285+ s.putTables(st)286+ })287+ return nil288+}289+290+// editTable changes the table with key k with fn, recording the change.291+func (s *Sheet) editTable(k string, fn func(*Table)) {292+ st := s.Tables()293+ if i := slices.IndexFunc(st, func(t Table) bool { return nameKey(t.Name) == k }); i >= 0 {294+ fn(&st[i])295+ s.putTables(st)296+ }297+}298+299+// putTables replaces the sheet's tables, recording the view for undo,300+// and has the formulas that read the tables changed recalculated. A301+// filter on a table's range follows the table as it grows or shrinks.302+// Every change to tables goes through here.303+func (s *Sheet) putTables(st []Table) {304+ if slices.EqualFunc(st, s.view.tables, Table.equal) {305+ return306+ }307+ s.recordView()308+ s.followFilter(s.view.tables, st)309+ w := s.wb310+ if w.hist.open != nil {311+ w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, st)...)312+ }313+ s.view.tables = st314+}315+316+// followFilter moves the filter along with the table whose range it317+// has, when the table's range changes.318+func (s *Sheet) followFilter(before, after []Table) {319+ f := s.view.filter320+ if f == nil {321+ return322+ }323+ for _, t := range before {324+ i := slices.IndexFunc(after, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) })325+ if t.Range != f.Range || i < 0 || after[i].Range == t.Range {326+ continue327+ }328+ nf := f.clone()329+ nf.Range = after[i].Range330+ for c := range nf.Cols {331+ if c > nf.Range.To.Col {332+ delete(nf.Cols, c)333+ }334+ }335+ s.view.filter = nf336+ s.hidden.valid = false337+ return338+ }339+}340+341+// tableUsers are the formulas reading any table in either list whose342+// range, columns or name differ between them.343+func (w *Workbook) tableUsers(before, after []Table) []loc {344+ var out []loc345+ add := func(k string) {346+ for u := range w.nameUsers[k] {347+ out = append(out, u)348+ }349+ }350+ for _, t := range before {351+ i := slices.IndexFunc(after, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) })352+ if i < 0 || !after[i].equal(t) {353+ add(nameKey(t.Name))354+ }355+ }356+ for _, t := range after {357+ if !slices.ContainsFunc(before, func(u Table) bool { return nameKey(u.Name) == nameKey(t.Name) }) {358+ add(nameKey(t.Name))359+ }360+ }361+ return out362+}
A internal/sheet/table_test.go +271 -0
@@ -0,0 +1,271 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// salesSheet is a sheet with a table Sales over A1:C4: Region, Units and10+// Amount, three rows of data.11+func salesSheet(t *testing.T) *Sheet {12+ t.Helper()13+ s := sheetOf(t, map[string]string{14+ "A1": "Region", "B1": "Units", "C1": "Amount",15+ "A2": "North", "B2": "2", "C2": "10",16+ "A3": "South", "B3": "3", "C3": "20",17+ "A4": "East", "B4": "5", "C4": "30",18+ })19+ if err := s.CreateTable("Sales", rect("A1:C4")); err != nil {20+ t.Fatal(err)21+ }22+ return s23+}24+25+// entry is the formula or entry at a.26+func entry(s *Sheet, a string) string {27+ if c := s.Cell(at(a)); c != nil {28+ return c.Input29+ }30+ return ""31+}32+33+func TestTableStructuredReferences(t *testing.T) {34+ s := salesSheet(t)35+ for a, f := range map[string]string{36+ "E1": "=SUM(Sales[Amount])",37+ "E2": "=ROWS(Sales[#All])",38+ "E3": "=Sales[[#Headers],[Units]]",39+ "E4": "=COLUMNS(Sales)*10+ROWS(Sales)",40+ "E5": "=SUM(Sales[[Units]:[Amount]])",41+ "D2": "=Sales[@Units]*Sales[@Amount]",42+ "E6": "=Sales[@Amount]",43+ "E7": "=SUM(Sales[Nope])",44+ "E8": "=SUM(Nope[Amount])",45+ "E9": "=ROWS(Sales[#Data])+ROWS(sales[#headers])",46+ } {47+ if err := s.Set(at(a), f); err != nil {48+ t.Fatalf("%s: %v", f, err)49+ }50+ }51+ wantShown(t, s, map[string]string{52+ "E1": "60", "E2": "4", "E3": "Units", "E4": "33", "E5": "70", "D2": "20",53+ "E6": "#REF!", "E7": "#REF!", "E8": "#REF!", "E9": "4",54+ })55+ // Changing a cell of the table recalculates what reads it.56+ s.Set(at("C2"), "100")57+ wantShown(t, s, map[string]string{"E1": "150", "D2": "200"})58+ if deps := s.Dependents(at("C3")); len(deps) == 0 {59+ t.Error("no dependents traced through the table")60+ }61+}62+63+// Rows typed or pasted just below a table join it, in the same undo64+// step as the entry.65+func TestTableGrows(t *testing.T) {66+ s := salesSheet(t)67+ s.Set(at("E1"), "=SUM(Sales[Amount])")68+ s.Set(at("C5"), "40")69+ if tb, _ := s.TableAt(at("C5")); tb.Range != rect("A1:C5") {70+ t.Fatalf("table %v", tb.Range)71+ }72+ wantShown(t, s, map[string]string{"E1": "100"})73+ s.Set(at("A7"), "not next to it")74+ if _, ok := s.TableAt(at("A7")); ok {75+ t.Error("a row with a gap joined the table")76+ }77+ s.Undo()78+ s.Undo()79+ if tb, _ := s.TableAt(at("A1")); tb.Range != rect("A1:C4") {80+ t.Errorf("undo left %v", tb.Range)81+ }82+ wantShown(t, s, map[string]string{"E1": "60"})83+ clip := s.Copy(rect("A2:C3"))84+ if _, err := s.Paste(clip, rect("A5"), false); err != nil {85+ t.Fatal(err)86+ }87+ if tb, _ := s.TableAt(at("A1")); tb.Range != rect("A1:C6") {88+ t.Errorf("paste below grew it to %v", tb.Range)89+ }90+ wantShown(t, s, map[string]string{"E1": "90"})91+}92+93+// A header changed in place renames its column in the formulas that94+// read it; blank and repeated headers get names of their own.95+func TestTableHeaderRenames(t *testing.T) {96+ s := salesSheet(t)97+ s.Set(at("E1"), "=SUM(Sales[Amount])+Sales[[#Headers],[Units]:[Amount]]")98+ s.Set(at("C1"), "Revenue")99+ if got := entry(s, "E1"); got != "=SUM(Sales[Revenue])+Sales[[#Headers],[Units]:[Revenue]]" {100+ t.Errorf("formula after renaming: %s", got)101+ }102+ s.Undo()103+ if got := entry(s, "E1"); got != "=SUM(Sales[Amount])+Sales[[#Headers],[Units]:[Amount]]" {104+ t.Errorf("formula after undo: %s", got)105+ }106+ s.Set(at("B1"), "")107+ s.Set(at("A1"), "Amount")108+ tb, _ := s.TableAt(at("A1"))109+ if strings.Join(tb.Cols, ",") != "Amount2,Column2,Amount" || entry(s, "A1") != "Amount2" || entry(s, "B1") != "Column2" {110+ t.Errorf("columns %v, A1 %q, B1 %q", tb.Cols, entry(s, "A1"), entry(s, "B1"))111+ }112+}113+114+// Columns inserted, deleted and moved keep formulas reading columns by115+// name; a deleted column's references show #REF!.116+func TestTableColumnsShift(t *testing.T) {117+ s := salesSheet(t)118+ s.Set(at("F1"), "=SUM(Sales[Amount])")119+ s.Set(at("F2"), "=SUM(Sales[Units])")120+ s.InsertCols(1, 1)121+ tb, _ := s.TableAt(at("A1"))122+ if tb.Range != rect("A1:D4") || strings.Join(tb.Cols, ",") != "Region,Column2,Units,Amount" {123+ t.Fatalf("after insert: %v %v", tb.Range, tb.Cols)124+ }125+ wantShown(t, s, map[string]string{"G1": "60", "G2": "10", "B1": "Column2"})126+ s.DeleteCols(2, 1)127+ wantShown(t, s, map[string]string{"F1": "60", "F2": "#REF!"})128+ if got := entry(s, "F2"); got != "=SUM(Sales[Units])" {129+ t.Errorf("formula after delete: %s", got)130+ }131+ // The whole table moved: formulas follow it.132+ if _, err := s.Move(rect("A1:C4"), at("A10")); err != nil {133+ t.Fatal(err)134+ }135+ if tb, _ := s.TableAt(at("A10")); tb.Name != "Sales" || tb.Range != rect("A10:C13") {136+ t.Errorf("after move: %v", tb)137+ }138+ wantShown(t, s, map[string]string{"F1": "60"})139+ s.DeleteRows(9, 1)140+ if s.HasTables() {141+ t.Error("a table kept without its header row")142+ }143+ wantShown(t, s, map[string]string{"F1": "#REF!"})144+}145+146+// Filling a formula down a table reads each row's own cells; copying147+// keeps structured references as they are.148+func TestTableFillAndCopy(t *testing.T) {149+ s := salesSheet(t)150+ if err := s.Book().ResizeTable("Sales", rect("A1:D4")); err != nil {151+ t.Fatal(err)152+ }153+ s.Set(at("D1"), "Total")154+ s.Set(at("D2"), "=Sales[@Units]*Sales[@Amount]")155+ if _, err := s.FillDown(rect("D2:D4")); err != nil {156+ t.Fatal(err)157+ }158+ wantShown(t, s, map[string]string{"D2": "20", "D3": "60", "D4": "150"})159+ if got := entry(s, "D4"); got != "=Sales[@Units]*Sales[@Amount]" {160+ t.Errorf("filled %s", got)161+ }162+ clip := s.Copy(rect("D2"))163+ s.Paste(clip, rect("H9"), false)164+ if got := entry(s, "H9"); got != "=Sales[@Units]*Sales[@Amount]" {165+ t.Errorf("pasted %s", got)166+ }167+ wantShown(t, s, map[string]string{"H9": "#REF!"})168+}169+170+func TestTableRenameAndRemove(t *testing.T) {171+ s := salesSheet(t)172+ w := s.Book()173+ s.Set(at("E1"), "=SUM(Sales[Amount])+ROWS(Sales)")174+ s.Set(at("D3"), "=Sales[@Amount]")175+ if err := w.RenameTable("sales", "Revenue"); err != nil {176+ t.Fatal(err)177+ }178+ if got := entry(s, "E1"); got != "=SUM(Revenue[Amount])+ROWS(Revenue)" {179+ t.Errorf("after rename: %s", got)180+ }181+ if err := w.RemoveTable("Revenue"); err != nil {182+ t.Fatal(err)183+ }184+ if got := entry(s, "E1"); got != "=SUM($C$2:$C$4)+ROWS($A$2:$C$4)" {185+ t.Errorf("after remove: %s", got)186+ }187+ if got := entry(s, "D3"); got != "=$C3" {188+ t.Errorf("this row after remove: %s", got)189+ }190+ wantShown(t, s, map[string]string{"E1": "63", "D3": "20"})191+ s.Undo()192+ if got := entry(s, "E1"); got != "=SUM(Revenue[Amount])+ROWS(Revenue)" || !s.HasTables() {193+ t.Errorf("undo remove: %s", got)194+ }195+}196+197+func TestTableNames(t *testing.T) {198+ s := salesSheet(t)199+ w := s.Book()200+ if err := s.CreateTable("Other", rect("B2:D6")); err != ErrTableOverlap {201+ t.Errorf("overlap: %v", err)202+ }203+ for _, name := range []string{"Sales", "A1", "nu.x", "", "two words"} {204+ if err := s.CreateTable(name, rect("H1:I3")); err == nil {205+ t.Errorf("%q named a table", name)206+ }207+ }208+ if err := w.DefineName("sales", s, rect("A1")); err == nil {209+ t.Error("a named range took a table's name")210+ }211+ if w.NextTableName() != "Table1" {212+ t.Errorf("next name %s", w.NextTableName())213+ }214+ if err := s.CreateTable("One", rect("H1:I1")); err != nil {215+ t.Fatal(err)216+ }217+ if tb, _ := s.TableAt(at("H1")); tb.Range != rect("H1:I2") || strings.Join(tb.Cols, ",") != "Column1,Column2" {218+ t.Errorf("a one-row table: %v %v", tb.Range, tb.Cols)219+ }220+}221+222+// A filter on a table's range grows with the table.223+func TestTableFilterFollows(t *testing.T) {224+ s := salesSheet(t)225+ s.CreateFilter(rect("A1:C4"))226+ s.Set(at("A5"), "West")227+ if r, _ := s.FilterRange(); r != rect("A1:C5") {228+ t.Errorf("filter %v", r)229+ }230+}231+232+func TestTableFileRoundTrip(t *testing.T) {233+ s := salesSheet(t)234+ w := s.Book()235+ w.SetTableStyle("Sales", false, true)236+ s.Set(at("E1"), "=SUM(Sales[Amount])")237+ var b bytes.Buffer238+ if err := s.Write(&b); err != nil {239+ t.Fatal(err)240+ }241+ if !strings.Contains(b.String(), `"version": 6`) || !strings.Contains(b.String(), `"tables": [{"name":"Sales","range":"A1:C4","columns":["Region","Units","Amount"],"header":true}]`) {242+ t.Fatalf("file:\n%s", b.String())243+ }244+ got, err := Read(&b)245+ if err != nil {246+ t.Fatal(err)247+ }248+ tb, ok := got.TableAt(at("B2"))249+ if !ok || tb.Name != "Sales" || tb.Banded || !tb.Header {250+ t.Errorf("read back %+v", tb)251+ }252+ wantShown(t, got, map[string]string{"E1": "60"})253+}254+255+// A notebook cell's output sent to a sheet is a table by the region's256+// name; nu.name stays the whole table.257+func TestRegionIsTable(t *testing.T) {258+ s := New()259+ sent(t, s, "app", "B2")260+ apply(t, s, LiveOp{Region: "app", Reset: true, Header: liveRow("status", "ms"), Rows: []LiveRow{liveRow("ok", "12"), liveRow("fail", "30")}})261+ s.Set(at("A1"), "=SUM(app[ms])")262+ s.Set(at("A2"), "=ROWS(app)")263+ s.Set(at("A3"), "=ROWS(nu.app)")264+ s.Set(at("A4"), "=COUNTIF(app[status],\"ok\")")265+ wantShown(t, s, map[string]string{"A1": "42", "A2": "2", "A3": "3", "A4": "1"})266+ apply(t, s, LiveOp{Region: "app", Reset: true, Header: liveRow("status", "ms"), Rows: []LiveRow{liveRow("ok", "1")}})267+ wantShown(t, s, map[string]string{"A1": "1", "A2": "1"})268+ if err := s.CreateTable("app", rect("H1:I3")); err == nil {269+ t.Error("a table took a region's name")270+ }271+}
A internal/sheet/tablebind.go +274 -0
@@ -0,0 +1,274 @@
1+package sheet2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/formula"7+)8+9+// How formulas read tables (table.go): a structured reference,10+// Sales[Amount], is kept as written and turned into the range it names11+// each time the formula is evaluated, as named ranges are (names.go), so12+// a table that grows, or whose columns move, is followed without13+// rewriting a formula. A formula reading a table is one of the table's14+// users, by its name's key, so a change to the table's cells or to the15+// table itself recalculates it.16+//17+// A name alone that names no range but a table stands for the table's18+// data rows, Sales as Sales[]. A region's table is found by the region's19+// name the same way: app[status], app alone for its rows under the20+// header, and nu.app, the whole table with its header row, as app[#All].21+22+// tableView is a table as formulas read it: a table's, or a region's.23+type tableView struct {24+ s *Sheet25+ r Rect // header row included26+ cols []string // the columns' names27+ ok bool // false for a region with no table shown: #REF!28+}29+30+// findTable finds the table or region formulas name with the key k.31+func (w *Workbook) findTable(k string) (tableView, bool) {32+ for _, s := range w.sheets {33+ if i := s.tableIndex(k); i >= 0 {34+ t := s.view.tables[i]35+ return tableView{s: s, r: t.Range, cols: t.Cols, ok: true}, true36+ }37+ }38+ for _, s := range w.sheets {39+ if i := s.regionIndex(k); i >= 0 {40+ r, ok := s.RegionTable(s.regions.list[i].Name)41+ if !ok {42+ return tableView{s: s}, true43+ }44+ return tableView{s: s, r: r, cols: s.rowTexts(r), ok: true}, true45+ }46+ }47+ return tableView{}, false48+}49+50+// rowTexts are the texts of r's first row, as shown.51+func (s *Sheet) rowTexts(r Rect) []string {52+ out := make([]string, 0, r.To.Col-r.From.Col+1)53+ for c := r.From.Col; c <= r.To.Col; c++ {54+ out = append(out, strings.TrimSpace(s.ShownText(Addr{Col: c, Row: r.From.Row})))55+ }56+ return out57+}58+59+// col is the index of the column named name, ignoring case, or -1.60+func (v tableView) col(name string) int {61+ for i, c := range v.cols {62+ if strings.EqualFold(c, name) {63+ return i64+ }65+ }66+ return -167+}68+69+// rows are the first and last row it names on the table's sheet, for a70+// formula at a on the sheet same says whether it's the table's.71+func (v tableView) rows(it formula.TableItems, a Addr, same bool) (int, int, bool) {72+ h, last := v.r.From.Row, v.r.To.Row73+ if it == formula.ItemThisRow {74+ if !same || a.Row <= h || a.Row > last {75+ return 0, 0, false76+ }77+ return a.Row, a.Row, true78+ }79+ lo, hi := -1, -180+ if it&formula.ItemHeaders != 0 {81+ lo, hi = h, h82+ }83+ if it&formula.ItemData != 0 && last > h {84+ if lo < 0 {85+ lo = h + 186+ }87+ hi = last88+ }89+ return lo, hi, lo >= 090+}91+92+// colSpan is the first and last column t names on the sheet.93+func (v tableView) colSpan(t formula.TableRef) (int, int, bool) {94+ from, to := t.Cols()95+ if from == "" {96+ return v.r.From.Col, v.r.To.Col, true97+ }98+ i, j := v.col(from), v.col(to)99+ if i < 0 || j < 0 {100+ return 0, 0, false101+ }102+ return v.r.From.Col + min(i, j), v.r.From.Col + max(i, j), true103+}104+105+// bindTable is what the structured reference t in a formula at a on s106+// stands for: a cell or a range, or #REF! when the table, a column or107+// the row it names isn't there. thisRow is the absolute markers a108+// reference to the formula's own row takes; every other is absolute.109+func (s *Sheet) bindTable(a Addr, t formula.TableRef, thisRow formula.Abs) Node {110+ v, found := s.wb.findTable(nameKey(t.Table))111+ if !found || !v.ok {112+ return formula.RefErr{}113+ }114+ lo, hi, okRows := v.rows(t.Rows(), a, v.s == s)115+ c0, c1, okCols := v.colSpan(t)116+ if !okRows || !okCols {117+ return formula.RefErr{}118+ }119+ sheet := ""120+ if v.s != s {121+ sheet = v.s.name122+ }123+ abs := formula.AbsCol | formula.AbsRow124+ if t.Items == formula.ItemThisRow {125+ abs = thisRow126+ }127+ r := Rect{From: Addr{Col: c0, Row: lo}, To: Addr{Col: c1, Row: hi}}128+ if r.From == r.To {129+ return formula.Ref{Addr: r.From, Abs: abs, Sheet: sheet}130+ }131+ return formula.Range{Rect: r, Abs: [2]formula.Abs{abs, abs}, Sheet: sheet}132+}133+134+// bound returns the formula at a in c with its names and structured135+// references replaced by the ranges they stand for: what is evaluated136+// and what dependencies are traced through. Undefined names stay, and137+// evaluate to #NAME?.138+func (s *Sheet) bound(a Addr, c *Cell) Node {139+ if len(c.names) == 0 {140+ return c.expr141+ }142+ const fixed = formula.AbsCol | formula.AbsRow143+ n, _ := formula.Rewrite(c.expr, formula.Rewriter{144+ Name: func(nn formula.Name) Node {145+ nm, ok := s.wb.names[nameKey(nn.Name)]146+ if !ok {147+ return s.boundTableName(a, nn)148+ }149+ if nm.Gone() {150+ return formula.RefErr{}151+ }152+ sheet := ""153+ if nm.Sheet != s {154+ sheet = nm.Sheet.name155+ }156+ if nm.Range.From == nm.Range.To {157+ return formula.Ref{Addr: nm.Range.From, Abs: fixed, Sheet: sheet}158+ }159+ return formula.Range{Rect: nm.Range, Abs: [2]formula.Abs{fixed, fixed}, Sheet: sheet}160+ },161+ Table: func(t formula.TableRef) Node { return s.bindTable(a, t, fixed) },162+ })163+ return n164+}165+166+// boundTableName is a name in a formula at a that isn't a named range:167+// a table's data rows (Sales), a region's whole table (nu.app), or the168+// name as written, which shows #NAME?.169+func (s *Sheet) boundTableName(a Addr, nn formula.Name) Node {170+ if _, found := s.wb.findTable(nameKey(nn.Name)); found {171+ return s.bindTable(a, formula.TableRef{Table: nn.Name}, formula.AbsCol|formula.AbsRow)172+ }173+ return s.boundRegion(nn)174+}175+176+// tablesInUse are the tables and regions formulas name, by structured177+// references or their names alone, with their cells, for finding what178+// reads a changed cell. A region named as nu.app is among regionsInUse.179+func (w *Workbook) tablesInUse() []namedUsers {180+ var out []namedUsers181+ for k, users := range w.nameUsers {182+ if _, isName := w.names[k]; isName {183+ continue184+ }185+ if v, ok := w.findTable(k); ok && v.ok {186+ out = append(out, namedUsers{v.s, v.r, users})187+ }188+ }189+ return out190+}191+192+// renameTableInFormulas rewrites the formulas reading the table with193+// key from to name it to instead.194+func (w *Workbook) renameTableInFormulas(from, to string) {195+ w.rewriteUsers(from, formula.Rewriter{196+ Name: func(n formula.Name) Node {197+ if nameKey(n.Name) == from {198+ return formula.Name{Name: to}199+ }200+ return n201+ },202+ Table: func(t formula.TableRef) Node {203+ if nameKey(t.Table) == from {204+ t.Table = to205+ }206+ return t207+ },208+ })209+}210+211+// renameColumnInFormulas rewrites the structured references to the212+// column old of the table with key k to name the column name instead.213+func (w *Workbook) renameColumnInFormulas(k, old, name string) {214+ w.rewriteUsers(k, formula.Rewriter{Table: func(t formula.TableRef) Node {215+ if nameKey(t.Table) != k {216+ return t217+ }218+ if strings.EqualFold(t.From, old) {219+ t.From = name220+ }221+ if strings.EqualFold(t.To, old) {222+ t.To = name223+ }224+ return t225+ }})226+}227+228+// unbindTable rewrites the formulas reading the table with key k to229+// read the cells it stands for by address: its column in a row of its230+// own relative to the formula (C5), the rest absolute ($C$2:$C$9).231+func (w *Workbook) unbindTable(k string) {232+ var users []loc233+ for u := range w.nameUsers[k] {234+ users = append(users, u)235+ }236+ for _, u := range users {237+ c := u.s.cells.get(u.a)238+ out := c.rewritten(formula.Rewriter{239+ Name: func(n formula.Name) Node {240+ if nameKey(n.Name) != k {241+ return n242+ }243+ if _, isName := w.names[k]; isName {244+ return n245+ }246+ return u.s.bindTable(u.a, formula.TableRef{Table: n.Name}, 0)247+ },248+ Table: func(t formula.TableRef) Node {249+ if nameKey(t.Table) != k {250+ return t251+ }252+ return u.s.bindTable(u.a, t, formula.AbsCol)253+ },254+ })255+ if out != c {256+ u.s.place(u.a, out)257+ }258+ }259+}260+261+// rewriteUsers rewrites the formulas using the name or table with key262+// k with rw.263+func (w *Workbook) rewriteUsers(k string, rw formula.Rewriter) {264+ var users []loc265+ for u := range w.nameUsers[k] {266+ users = append(users, u)267+ }268+ for _, u := range users {269+ c := u.s.cells.get(u.a)270+ if out := c.rewritten(rw); out != c {271+ u.s.place(u.a, out)272+ }273+ }274+}
A internal/sheet/tablefile.go +64 -0
@@ -0,0 +1,64 @@
1+package sheet2+3+import (4+ "fmt"5+)6+7+// Tables in the file (version 6): a sheet's tables are a list in its8+// view, each with its name, its range, header row included, its9+// columns' names and its style:10+//11+// "tables": [{"name":"Sales","range":"A1:C20","columns":["Region","Month","Amount"],"banded":true,"header":true}],12+//13+// They need a version: a build without tables can't read the formulas14+// that read them.15+type fileTable struct {16+ Name string `json:"name"`17+ Range string `json:"range"`18+ Columns []string `json:"columns,omitempty"`19+ Banded bool `json:"banded,omitempty"`20+ Header bool `json:"header,omitempty"`21+}22+23+// hasTables reports whether any sheet has a table.24+func (w *Workbook) hasTables() bool {25+ for _, s := range w.sheets {26+ if s.HasTables() {27+ return true28+ }29+ }30+ return false31+}32+33+// encodeTables is the sheet's tables as the file holds them.34+func encodeTables(ts []Table) []fileTable {35+ out := make([]fileTable, len(ts))36+ for i, t := range ts {37+ out[i] = fileTable{Name: t.Name, Range: t.Range.String(), Columns: t.Cols, Banded: t.Banded, Header: t.Header}38+ }39+ return out40+}41+42+// readTables restores a sheet's tables from a file, its cells already43+// read. Columns the file doesn't name, or names in a number other than44+// the range's width, are named from the header row.45+func (s *Sheet) readTables(fts []fileTable) error {46+ for _, ft := range fts {47+ if err := s.wb.checkTableName(ft.Name, ""); err != nil {48+ return fmt.Errorf("table %q: %w", ft.Name, err)49+ }50+ r, ok := ParseRange(ft.Range)51+ if !ok || r.To.Row == r.From.Row {52+ return fmt.Errorf("table %q: invalid range %q", ft.Name, ft.Range)53+ }54+ if err := s.checkTableRange(r, ""); err != nil {55+ return fmt.Errorf("table %q: %w", ft.Name, err)56+ }57+ t := Table{Name: ft.Name, Range: r, Cols: columnNames(ft.Columns, nil), Banded: ft.Banded, Header: ft.Header}58+ if len(t.Cols) != r.To.Col-r.From.Col+1 {59+ t.Cols = columnNames(s.rowTexts(Rect{From: r.From, To: Addr{Col: r.To.Col, Row: r.From.Row}}), nil)60+ }61+ s.view.tables = append(s.view.tables, t)62+ }63+ return nil64+}
A internal/sheet/tablesync.go +252 -0
@@ -0,0 +1,252 @@
1+package sheet2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// Keeping tables (table.go) in step with their cells. At the end of12+// every change, before recalculating, a table grows over the rows just13+// below it that the change filled, and its columns' names are read14+// again from its header row when the change touched it: a name changed15+// in place renames the column in the formulas that read it, and a16+// blank or repeated header is given a name of its own. Undo puts back17+// the tables with the view, and the formulas and cells with theirs, so18+// it runs none of this.19+20+// settleTables brings the tables on the sheets the open step changed21+// in step with their cells, within the step.22+func (w *Workbook) settleTables() {23+ var touched map[*Sheet][]Addr24+ for _, l := range w.hist.dirty {25+ if len(l.s.view.tables) == 0 || !l.s.live {26+ continue27+ }28+ if touched == nil {29+ touched = map[*Sheet][]Addr{}30+ }31+ touched[l.s] = append(touched[l.s], l.a)32+ }33+ for s, at := range touched {34+ for _, t := range s.Tables() {35+ s.growTable(t, at)36+ }37+ for _, t := range s.Tables() {38+ if headerTouched(t, at) {39+ s.syncHeader(t)40+ }41+ }42+ }43+}44+45+// headerTouched reports whether a cell of t's header row is among at,46+// or t's columns don't match its width.47+func headerTouched(t Table, at []Addr) bool {48+ if len(t.Cols) != t.Range.To.Col-t.Range.From.Col+1 {49+ return true50+ }51+ return slices.ContainsFunc(at, func(a Addr) bool {52+ return a.Row == t.Range.From.Row && a.Col >= t.Range.From.Col && a.Col <= t.Range.To.Col53+ })54+}55+56+// growTable extends t over the rows just below it that cells among at,57+// filled in t's columns, make a run from its last row.58+func (s *Sheet) growTable(t Table, at []Addr) {59+ filled := map[int]bool{}60+ for _, a := range at {61+ if a.Row > t.Range.To.Row && a.Col >= t.Range.From.Col && a.Col <= t.Range.To.Col && s.cells.filledAt(a) {62+ filled[a.Row] = true63+ }64+ }65+ end := t.Range.To.Row66+ for filled[end+1] {67+ end++68+ }69+ if end == t.Range.To.Row {70+ return71+ }72+ r := t.Range73+ r.To.Row = end74+ if s.checkTableRange(r, nameKey(t.Name)) != nil {75+ return76+ }77+ s.editTable(nameKey(t.Name), func(t *Table) { t.Range = r })78+}79+80+// syncHeader reads t's columns' names again from its header row,81+// renaming in formulas each column whose name changed in place.82+func (s *Sheet) syncHeader(t Table) {83+ names := s.headerNames(t)84+ if slices.Equal(names, t.Cols) {85+ return86+ }87+ k := nameKey(t.Name)88+ for old, name := range renamedColumns(t.Cols, names) {89+ s.wb.renameColumnInFormulas(k, old, name)90+ }91+ s.editTable(k, func(t *Table) { t.Cols = names })92+}93+94+// renamedColumns pairs the names in before that are gone from after95+// with the names in their place that are new, by position: a column96+// renamed in its header. Names that moved, as when columns are inserted,97+// deleted or moved, rename nothing.98+func renamedColumns(before, after []string) map[string]string {99+ out := map[string]string{}100+ has := func(list []string, name string) bool {101+ return slices.ContainsFunc(list, func(n string) bool { return strings.EqualFold(n, name) })102+ }103+ for i := range min(len(before), len(after)) {104+ old, name := before[i], after[i]105+ if old == "" || old == name || has(after, old) || has(before, name) && !strings.EqualFold(old, name) {106+ continue107+ }108+ out[old] = name109+ }110+ return out111+}112+113+// headerNames reads the names of t's columns from its header row (see114+// columnNames), writing the names it gives blank or repeated headers115+// into their cells, so the header shows what formulas use.116+func (s *Sheet) headerNames(t Table) []string {117+ texts := s.rowTexts(Rect{From: t.Range.From, To: Addr{Col: t.Range.To.Col, Row: t.Range.From.Row}})118+ names := columnNames(texts, t.Cols)119+ for i, name := range names {120+ a := Addr{Col: t.Range.From.Col + i, Row: t.Range.From.Row}121+ if name == texts[i] {122+ continue123+ }124+ if _, spilled := s.SpillAnchor(a); !spilled {125+ s.put(a, name)126+ }127+ }128+ return names129+}130+131+// columnNames are the names of a table's columns whose header cells132+// show texts: each text, with a blank one named after its place133+// (Column3) and a repeat numbered (Amount2). Of two headers the same, the134+// one already a column's name in prev, the names before, keeps it.135+func columnNames(texts, prev []string) []string {136+ names := make([]string, len(texts))137+ seen := map[string]bool{}138+ for i, name := range texts {139+ if k := nameKey(name); name != "" && i < len(prev) && nameKey(prev[i]) == k && !seen[k] {140+ seen[k], names[i] = true, name141+ }142+ }143+ for i, name := range texts {144+ if k := nameKey(name); name != "" && names[i] == "" && !seen[k] {145+ seen[k], names[i] = true, name146+ }147+ }148+ for i, name := range names {149+ if name != "" {150+ continue151+ }152+ base := texts[i]153+ if base == "" {154+ base = "Column"155+ }156+ name = uniqueColumn(base, i+1, seen)157+ seen[nameKey(name)], names[i] = true, name158+ }159+ return names160+}161+162+// uniqueColumn is base numbered so no name in seen has it: base with163+// the column's place for a blank header (Column3), else from 2 up.164+func uniqueColumn(base string, place int, seen map[string]bool) string {165+ from := 2166+ if base == "Column" {167+ from = place168+ }169+ for i := from; ; i++ {170+ if name := base + strconv.Itoa(i); !seen[nameKey(name)] {171+ return name172+ }173+ }174+}175+176+// shiftTables moves tables with inserted or deleted rows or columns, as177+// a range reference moves. A table whose header row or every column is178+// deleted goes; one left without data rows keeps an empty one. Columns179+// inserted into a table come in unnamed, to be named from their header180+// cells when the change ends.181+func shiftTables(ts []Table, rows bool, sp formula.Span) []Table {182+ if len(ts) == 0 {183+ return ts184+ }185+ _, rng := formula.AxisMaps(rows, sp)186+ var out []Table187+ for _, t := range ts {188+ if rows && sp.N < 0 && t.Range.From.Row >= sp.At && t.Range.From.Row < sp.At-sp.N {189+ continue // the header row went190+ }191+ r, ok := rng(t.Range)192+ if !ok {193+ continue194+ }195+ t = t.clone()196+ if !rows {197+ t.Cols = shiftColumns(t, r, sp)198+ }199+ t.Range = tableRange(r)200+ out = append(out, t)201+ }202+ return out203+}204+205+// shiftColumns are t's columns' names once its range is r, after206+// columns were inserted or deleted: each kept column's name where the207+// column went, and "" for columns inserted.208+func shiftColumns(t Table, r Rect, sp formula.Span) []string {209+ out := make([]string, r.To.Col-r.From.Col+1)210+ for i, name := range t.Cols {211+ if c, ok := sp.Point(t.Range.From.Col + i); ok && c >= r.From.Col && c <= r.To.Col {212+ out[c-r.From.Col] = name213+ }214+ }215+ return out216+}217+218+// remapTables moves the tables wholly inside the cells moved with them,219+// as cut and paste moves them in Sheets; rng maps a range that moved.220+func (s *Sheet) remapTables(src Rect, rng func(Rect) (Rect, bool)) {221+ if len(s.view.tables) == 0 {222+ return223+ }224+ st := s.Tables()225+ for i, t := range st {226+ if src.Contains(t.Range.From) && src.Contains(t.Range.To) {227+ if r, ok := rng(t.Range); ok {228+ st[i].Range = r229+ }230+ }231+ }232+ s.putTables(st)233+}234+235+// moveTablesTo moves the tables wholly inside src on s to dst, shifted236+// by shift, as cutting them from one sheet and pasting on another does.237+func (s *Sheet) moveTablesTo(dst *Sheet, src Rect, shift func(Addr) Addr) {238+ var moved, kept []Table239+ for _, t := range s.Tables() {240+ if src.Contains(t.Range.From) && src.Contains(t.Range.To) {241+ t.Range = Rect{From: shift(t.Range.From), To: shift(t.Range.To)}242+ moved = append(moved, t)243+ } else {244+ kept = append(kept, t)245+ }246+ }247+ if len(moved) == 0 {248+ return249+ }250+ s.putTables(kept)251+ dst.putTables(append(dst.Tables(), moved...))252+}
M internal/sheet/trace.go +5 -5
M internal/sheet/view.go +17 -4
@@ -11,14 +11,15 @@ import (
1111 )1212 1313 // View state that belongs to the worksheet rather than to a cell: frozen14-// rows and columns, the filter, the protected ranges and the merged15-// cells. It is saved in the file and every change to it is an undo step,14+// rows and columns, the filter, the protected ranges, the merged cells15+// and the tables. It is saved in the file and every change to it is an undo step,1616 // as in Sheets.1717 type viewState struct {1818 frozenRows, frozenCols int1919 filter *Filter // never modified in place; replaced whole2020 protected []Protection // see protect.go; replaced whole too2121 merges []Rect // see merge.go; replaced whole too22+ tables []Table // see table.go; replaced whole too2223 }2324 2425 // Frozen returns how many rows and columns are frozen at the top and left.
@@ -80,7 +81,8 @@ func (s *Sheet) recordView() {
8081 func (v viewState) equal(w viewState) bool {8182 return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&8283 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter)) &&83- slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges)84+ slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges) &&85+ slices.EqualFunc(v.tables, w.tables, Table.equal)8486 }8587 8688 // shiftView keeps frozen lines, the filter and protected ranges in step
@@ -92,6 +94,7 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
9294 v := s.view9395 v.protected = shiftProtected(v.protected, rows, sp)9496 v.merges = shiftMerges(v.merges, rows, sp)97+ v.tables = shiftTables(v.tables, rows, sp)9598 frozen := &v.frozenCols9699 if rows {97100 frozen = &v.frozenRows
@@ -124,6 +127,9 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
124127 }125128 if !v.equal(s.view) {126129 s.recordView()130+ if w := s.wb; w.hist.open != nil {131+ w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, v.tables)...)132+ }127133 s.view = v128134 }129135 }
@@ -138,7 +144,8 @@ type fileView struct {
138144 Protected []fileProtection `json:"protected,omitempty"`139145 // Merges need no version: earlier builds ignore them and show the140146 // cells unmerged.141- Merges []string `json:"merges,omitempty"`147+ Merges []string `json:"merges,omitempty"`148+ Tables []fileTable `json:"tables,omitempty"` // version 6, see tablefile.go142149 }143150 144151 type fileFreeze struct {
@@ -182,6 +189,9 @@ func (s *Sheet) writeView(b *bufio.Writer, indent string) error {
182189 }183190 keys, parts = append(keys, "merges"), append(parts, names)184191 }192+ if ts := s.view.tables; len(ts) > 0 {193+ keys, parts = append(keys, "tables"), append(parts, encodeTables(ts))194+ }185195 for i, p := range parts {186196 raw, err := json.Marshal(p)187197 if err != nil {
@@ -207,6 +217,9 @@ func (s *Sheet) readView(fv fileView) error {
207217 }208218 s.LoadMerge(r)209219 }220+ if err := s.readTables(fv.Tables); err != nil {221+ return err222+ }210223 if fz := fv.Freeze; fz != nil {211224 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)212225 }
M internal/sheet/workbook.go +3 -2