cat / 012
clone

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

Port hidden sheets and missing-sheet formulas to the new XLSX code

Dami committed 59bfdb3
From main's UX batch: sheets Excel hid import hidden (but the active
one), 012's hidden sheets export with state="hidden", and formulas
naming a sheet the file doesn't have export as their values with a
note. A sheet renamed to suit Excel counts as missing, so formulas
naming it go out as values too. Needs main's sheet package (HideSheet,
NamedSheets, Snapshot.Hidden, SnapCell.Sheets) to build.
commit
59bfdb3fb8bbff606e1dc10eaf23b4e4a04ab336
parent
71a503a2343bafd59f62968aaa11e2991f128e0a
author
Dami <dami.etoile@gmail.com>
8 files changed, +210 -65
Mdocs/files.md17+++++-
Minternal/fileio/xlsx.go30+++++++--
Minternal/fileio/xlsx_diff_test.go12+---
Minternal/fileio/xlsx_excelize_test.go5++
Minternal/fileio/xlsx_test.go85++++++++++++++++++++++++++
Minternal/fileio/xlsximport.go22++-----
Minternal/fileio/xlsxread_test.go5+-
Minternal/fileio/xlsxwrite.go99++++++++++++++++++++++--------
M docs/files.md +16 -1
@@ -7,10 +7,19 @@ through File > Download. Imports run in the background with a progress
77 bar; Esc cancels. Saving an imported sheet asks whether to save it as a88 `.012` file or download it back in its format.99 10+File > Import asks where the data goes, as Sheets' Import location does11+(a new, empty spreadsheet is simply replaced):12+13+| Location | Does |14+|---|---|15+| Insert new sheet(s) | Adds the file's sheets after the others: every sheet of an `.xlsx`, named after the file for other formats. A name already taken gets a number (`Sales 2`) and the file's formulas follow it; named ranges come along unless their name is taken. One undo step, and the spreadsheet stays the file you're editing |16+| Replace current sheet | Puts the data in place of the sheet shown, keeping its name and position, so formulas and named ranges that read it read the new data. One undo step. Not offered for `.xlsx`, which holds several sheets |17+| Replace spreadsheet | Opens the file instead, as File > Open and the command line do, asking first when there are unsaved changes |18+1019 | Format | Import | Download |1120 |---|---|---|1221 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |13-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, shared formulas, dates in the 1904 system. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed; hidden sheets show, with a note. Files that unpack past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values, and so do pivot tables: Excel gets the results, not a pivot |22+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, shared formulas, dates in the 1904 system; sheets Excel hid stay hidden (unless it's the one Excel showed). Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed. Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values, and so do formulas naming a sheet that doesn't exist (their `#REF!`; Excel would refuse the reference) or a sheet whose name Excel can't take as is (renamed in the file, e.g. `Plan (2)`), and pivot tables: Excel gets the results, not a pivot. The download's result counts the formulas saved as values, with an example |1423 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |1524 | Parquet | Every column, with dates and timestamps; lists joined with commas | |1625 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |
@@ -84,6 +93,12 @@ arithmetic, the sheet shown when saved) stay at the top:
8493 8594 Older builds refuse version 4 files rather than lose sheets.8695 96+A hidden sheet (Hide sheet on its tab) has `"hidden": true` after its97+name. It needs no version bump: builds without hidden sheets ignore the98+field and show the sheet. A file whose sheets are all hidden opens with99+the first one shown. XLSX downloads write hidden sheets hidden, and100+sheets hidden in Excel import hidden.101+87102 ## Pivot tables88103 89104 A workbook with a pivot table is version 5: version 4 with a `pivot`
M internal/fileio/xlsx.go +23 -7
@@ -9,6 +9,7 @@ import (
99 "strconv"1010 "strings"1111 12+ "github.com/FelineStateMachine/012/internal/formula"1213 "github.com/FelineStateMachine/012/internal/sheet"1314 )1415
@@ -40,16 +41,23 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
4041 if len(sheets) == 0 {4142 sheets = []*Snapshot{snap}4243 }43- w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1}44+ w := &xlsxWriter{styles: newXLSXStyleTable(), multi: len(sheets) > 1, known: map[string]bool{}}4445 res := &ExportResult{}45- names, active := make([]string, len(sheets)), 046+ names, hidden, active := make([]string, len(sheets)), make([]bool, len(sheets)), 04647 used := map[string]bool{}4748 for i, sn := range sheets {4849 names[i] = uniqueSheetName(sheetName(sn.Name), used)4950 if sn == snap {5051 active = i5152 }53+ hidden[i] = sn.Hidden54+ // A formula can name only a sheet written under its own name:55+ // one renamed to suit Excel goes out as values, as a missing one.56+ if names[i] == sn.Name {57+ w.known[formula.SheetKey(sn.Name)] = true58+ }5259 }60+ hidden[active] = false5361 err := writeFile(name, func(out io.Writer) error {5462 zw := zip.NewWriter(out)5563 for i, sn := range sheets {
@@ -62,7 +70,7 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
6270 if err := writePart(zw, "xl/styles.xml", w.styles.xml()); err != nil {6371 return err6472 }65- if err := writePackage(zw, names, active, snap.Names); err != nil {73+ if err := writePackage(zw, names, hidden, active, snap.Names); err != nil {6674 return err6775 }6876 return zw.Close()
@@ -70,9 +78,13 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
7078 if err != nil {7179 return nil, err7280 }73- if w.values > 0 {81+ if w.values.n > 0 {7482 res.Notes = append(res.Notes, fmt.Sprintf("%s with no Excel equivalent saved as values, e.g. %s",75- count(w.values, "formula", "formulas"), w.example))83+ count(w.values.n, "formula", "formulas"), w.values.example))84+ }85+ if w.missing.n > 0 {86+ res.Notes = append(res.Notes, fmt.Sprintf("%s naming a sheet that doesn't exist saved as values, e.g. %s (%s)",87+ count(w.missing.n, "formula", "formulas"), w.missing.example, sheet.QuoteSheet(w.missingSheet)))7688 }7789 return res, nil7890 }
@@ -101,7 +113,7 @@ const (
101113 // writePackage writes the parts around the worksheets and styles: the102114 // workbook with its sheets, active tab, names and calculation settings,103115 // the relationships and the content types.104-func writePackage(zw *zip.Writer, names []string, active int, defined [][2]string) error {116+func writePackage(zw *zip.Writer, names []string, hidden []bool, active int, defined [][2]string) error {105117 var types, rels, book strings.Builder106118 types.WriteString(xmlHead + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +107119 `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
@@ -115,7 +127,11 @@ func writePackage(zw *zip.Writer, names []string, active int, defined [][2]strin
115127 n := strconv.Itoa(i + 1)116128 fmt.Fprintf(&types, `<Override PartName="/xl/worksheets/sheet%s.xml" ContentType="%sworksheet+xml"/>`, n, mlType)117129 fmt.Fprintf(&rels, `<Relationship Id="rId%s" Type="%s/worksheet" Target="worksheets/sheet%s.xml"/>`, n, officeRel, n)118- fmt.Fprintf(&book, `<sheet name="%s" sheetId="%s" r:id="rId%s"/>`, escapeXML(ws, true), n, n)130+ state := ""131+ if hidden[i] {132+ state = ` state="hidden"`133+ }134+ fmt.Fprintf(&book, `<sheet name="%s" sheetId="%s"%s r:id="rId%s"/>`, escapeXML(ws, true), n, state, n)119135 }120136 fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="%s/styles" Target="styles.xml"/></Relationships>`, len(names)+1, officeRel)121137 types.WriteString(`</Types>`)
M internal/fileio/xlsx_diff_test.go +1 -11
M internal/fileio/xlsx_excelize_test.go +5 -0
M internal/fileio/xlsx_test.go +85 -0
@@ -375,6 +375,28 @@ func TestXLSXWriteText(t *testing.T) {
375375 }376376 }377377 378+// A sheet whose name Excel can't take as is gets another; formulas379+// naming it go out as values, like formulas naming a missing sheet.380+func TestXLSXRenamedSheetAsValue(t *testing.T) {381+ src := build(t, map[string]string{"A1": "1"})382+ book := src.Book()383+ book.RenameSheet(src, "Plan")384+ spaced, err := book.AddSheet(" Plan", 1)385+ if err != nil {386+ t.Fatal(err)387+ }388+ spaced.Set(addr(t, "A1"), "=' Plan'!A2+Plan!A1")389+ name := filepath.Join(t.TempDir(), "renamed.xlsx")390+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})391+ if err != nil {392+ t.Fatal(err)393+ }394+ want := "1 formula naming a sheet that doesn't exist saved as values, e.g. 'Plan (2)'!A1 (' Plan')"395+ if len(res.Notes) != 1 || res.Notes[0] != want {396+ t.Errorf("notes %q, want %q", res.Notes, want)397+ }398+}399+378400 func TestUniqueSheetName(t *testing.T) {379401 used := map[string]bool{}380402 long := strings.Repeat("x", 31)
@@ -391,3 +413,66 @@ func trim(s string) string {
391413 }392414 return s393415 }416+417+// A formula naming a sheet the workbook doesn't have goes out as its418+// value (#REF!), with a note, since Excel would refuse the reference.419+func TestXLSXMissingSheetAsValue(t *testing.T) {420+ src := build(t, map[string]string{"A1": "2", "B1": "=Gone!A1+1", "B2": "=A1*2", "B3": "=SUM('Old plan'!A1:A3, gone!B1)"})421+ name := filepath.Join(t.TempDir(), "missing.xlsx")422+ res, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{})423+ if err != nil {424+ t.Fatal(err)425+ }426+ want := "2 formulas naming a sheet that doesn't exist saved as values, e.g. B1 (Gone)"427+ if len(res.Notes) != 1 || res.Notes[0] != want {428+ t.Errorf("notes %q, want %q", res.Notes, want)429+ }430+ x, err := excelize.OpenFile(name)431+ if err != nil {432+ t.Fatal(err)433+ }434+ defer x.Close()435+ ws := x.GetSheetName(0)436+ for cell, formula := range map[string]string{"B1": "", "B2": "A1*2", "B3": ""} {437+ if got, _ := x.GetCellFormula(ws, cell); got != formula {438+ t.Errorf("%s formula %q, want %q", cell, got, formula)439+ }440+ }441+ if got, _ := x.GetCellValue(ws, "B1"); got != "#REF!" {442+ t.Errorf("B1 value %q", got)443+ }444+}445+446+// Hidden sheets go out to Excel hidden, and come back hidden.447+func TestXLSXHiddenSheets(t *testing.T) {448+ src := build(t, map[string]string{"A1": "=Data!A1*2"})449+ book := src.Book()450+ data, _ := book.AddSheet("Data", 1)451+ data.Set(addr(t, "A1"), "21")452+ if err := book.HideSheet(data); err != nil {453+ t.Fatal(err)454+ }455+ name := filepath.Join(t.TempDir(), "hidden.xlsx")456+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {457+ t.Fatal(err)458+ }459+ x, err := excelize.OpenFile(name)460+ if err != nil {461+ t.Fatal(err)462+ }463+ if shown, _ := x.GetSheetVisible("Data"); shown {464+ t.Error("Data is visible in Excel")465+ }466+ if shown, _ := x.GetSheetVisible(x.GetSheetName(0)); !shown {467+ t.Error("the first sheet is hidden in Excel")468+ }469+ x.Close()470+ got, err := Import(context.Background(), name, Options{})471+ if err != nil {472+ t.Fatal(err)473+ }474+ gb := got.Sheet.Book()475+ if !gb.Lookup("Data").Hidden() || got.Sheet.Hidden() || shown(got.Sheet, addr(t, "A1")) != "42" {476+ t.Errorf("read back: Data hidden %v, shown %s hidden %v", gb.Lookup("Data").Hidden(), got.Sheet.Name(), got.Sheet.Hidden())477+ }478+}
M internal/fileio/xlsximport.go +5 -17
M internal/fileio/xlsxread_test.go +4 -1
M internal/fileio/xlsxwrite.go +71 -28
@@ -9,6 +9,7 @@ import (
99 "strings"1010 "unicode/utf8"1111 12+ "github.com/FelineStateMachine/012/internal/formula"1213 "github.com/FelineStateMachine/012/internal/sheet"1314 )1415
@@ -18,11 +19,44 @@ const excelMaxText = 32767
1819 1920 // xlsxWriter writes worksheets, sharing cell formats among them.2021 type xlsxWriter struct {21- styles *xlsxStyleTable22- values int // formulas written as values23- example string // the first of them24- multi bool // examples name their sheet25- buf []byte // a row's cells22+ styles *xlsxStyleTable23+ multi bool // examples name their sheet24+ buf []byte // a row's cells25+26+ // Formulas are written as their values when Excel has no equivalent,27+ // or when they name a sheet the file doesn't have, which Excel would28+ // refuse.29+ values valueCount30+ missing valueCount31+ missingSheet string // the sheet missing's example names32+ known map[string]bool // keys of the sheets written33+}34+35+// valueCount counts formulas written as values, keeping the first one's36+// address as an example.37+type valueCount struct {38+ n int39+ example string40+}41+42+func (v *valueCount) add(multi bool, ws string, a sheet.Addr) {43+ v.n++44+ if v.example == "" {45+ v.example = a.String()46+ if multi {47+ v.example = sheet.QuoteSheet(ws) + "!" + a.String()48+ }49+ }50+}51+52+// unknownSheet returns a sheet c's formula names that isn't written.53+func (w *xlsxWriter) unknownSheet(c SnapCell) (string, bool) {54+ for _, name := range c.Sheets {55+ if !w.known[formula.SheetKey(name)] {56+ return name, true57+ }58+ }59+ return "", false2660 }2761 2862 // sheet writes a snapshot as a worksheet and returns the rows written.
@@ -74,20 +108,11 @@ func mapKeys(m map[int]int) func(func(int) bool) {
74108 }75109 76110 // cell appends snapshot cell c at a as a <c> element. A formula's value77-// is written as its cached result; a formula with no Excel equivalent78-// is written as its value alone, and counted.111+// is written as its cached result; a formula with no Excel equivalent,112+// or naming a sheet that isn't written, is written as its value alone,113+// and counted.79114 func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte {80- formula := ""81- if c.Formula {82- if fx, ok := toExcelFormula(c.Input); ok {83- formula = fx84- } else if w.values++; w.example == "" {85- w.example = a.String()86- if w.multi {87- w.example = sheet.QuoteSheet(ws) + "!" + a.String()88- }89- }90- }115+ fx := w.formula(ws, a, c)91116 b = append(b, `<c r="`...)92117 b = append(b, excelColName(a.Col+1)...)93118 b = strconv.AppendInt(b, int64(a.Row+1), 10)
@@ -99,23 +124,23 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
99124 b = strconv.AppendInt(b, int64(w.styles.id(c.Format, c.Style)), 10)100125 b = append(b, '"')101126 }102- typ, v, inline := cellValue(c)127+ typ, v, inline := cellValue(c, fx != "")103128 if typ != "" {104129 b = append(b, ` t="`...)105130 b = append(b, typ...)106131 b = append(b, '"')107132 }108- if formula == "" && v == "" {133+ if fx == "" && v == "" {109134 return append(b, "/>"...)110135 }111136 b = append(b, '>')112- if formula != "" {137+ if fx != "" {113138 b = append(b, "<f>"...)114- b = appendEscaped(b, formula, false)139+ b = appendEscaped(b, fx, false)115140 b = append(b, "</f>"...)116141 }117142 switch {118- case inline && formula == "":143+ case inline && fx == "":119144 b = append(b, `<is><t xml:space="preserve">`...)120145 b = appendEscaped(b, v, true)121146 b = append(b, "</t></is>"...)
@@ -127,11 +152,31 @@ func (w *xlsxWriter) cell(b []byte, ws string, a sheet.Addr, c SnapCell) []byte
127152 return append(b, "</c>"...)128153 }129154 155+// formula is c's formula in Excel's syntax, or "" when it has none or156+// is written as its value, which it counts.157+func (w *xlsxWriter) formula(ws string, a sheet.Addr, c SnapCell) string {158+ if !c.Formula {159+ return ""160+ }161+ if name, ok := w.unknownSheet(c); ok {162+ if w.missing.n == 0 {163+ w.missingSheet = name164+ }165+ w.missing.add(w.multi, ws, a)166+ return ""167+ }168+ fx, ok := toExcelFormula(c.Input)169+ if !ok {170+ w.values.add(w.multi, ws, a)171+ }172+ return fx173+}174+130175 // cellValue is a cell's value as Excel stores it: the type attribute,131176 // the text, and whether text goes inline (<is>) rather than in <v>,132177 // which only a formula's cached text does (t="str"). Errors are written133178 // as text, as Excel recalculates on opening.134-func cellValue(c SnapCell) (typ, v string, inline bool) {179+func cellValue(c SnapCell, hasFormula bool) (typ, v string, inline bool) {135180 switch c.Value.Kind {136181 case sheet.Number:137182 n := toExcelSerial(c.Value.Num, c.Format)
@@ -149,10 +194,8 @@ func cellValue(c SnapCell) (typ, v string, inline bool) {
149194 if utf8.RuneCountInString(s) > excelMaxText {150195 s = string([]rune(s)[:excelMaxText])151196 }152- if c.Formula {153- if _, ok := toExcelFormula(c.Input); ok {154- return "str", s, false155- }197+ if hasFormula {198+ return "str", s, false156199 }157200 return "inlineStr", s, true158201 }