cat / 012
clone

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

Linked sources in the engine: paged tabs, questions to a host, pivots over sources, format version 7

Dami committed 48e06e8
commit
48e06e8186034d9b8aeb260bd634b6f80fc1ef2a
parent
470224b1e63d2a3172639aeb89416fc6c8efdce7
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.8 of 3, Jev's estimate
21 files changed, +1090 -69
Minternal/sheet/explain.go3+
Minternal/sheet/file.go13+-
Minternal/sheet/linked.go8+-
Minternal/sheet/pivot.go13+-
Minternal/sheet/pivotcalc.go79+++------
Minternal/sheet/pivotcols.go6+-
Ainternal/sheet/pivotdata.go194+++++++++++++++++++++++
Minternal/sheet/pivotlayout.go15++-
Minternal/sheet/rangememo.go6+
Minternal/sheet/recalc.go10+
Minternal/sheet/region.go6+-
Minternal/sheet/regionfile.go12+-
Minternal/sheet/regionshape.go11+-
Minternal/sheet/regionwrite.go2+-
Minternal/sheet/sheet.go3+
Ainternal/sheet/source.go241++++++++++++++++++++++++++++
Ainternal/sheet/sourceask.go255++++++++++++++++++++++++++++++
Ainternal/sheet/sourcefile.go113+++++++++++++
Ainternal/sheet/sourceread.go154++++++++++++++++++
Minternal/sheet/tablebind.go11+
Minternal/sheet/workbook.go4+
M internal/sheet/explain.go +3 -0
M internal/sheet/file.go +10 -3
@@ -23,8 +23,10 @@ const FileExt = ".012"
2323 // a "name" field that earlier builds ignore.2424 // Version 5 is version 4 with pivot tables; a workbook without one is2525 // still written as version 4. Version 6 is version 5 with tables26-// (tablefile.go), written only for a workbook with one.27-const fileVersion = 626+// (tablefile.go), written only for a workbook with one. Version 7 is27+// version 6 with linked sources (sourcefile.go), written only for a28+// workbook linking one.29+const fileVersion = 72830 2931 // The file is JSON with one entry per cell, keyed by address. A cell3032 // without formatting is just its input, as in version 1; a formatted cell
@@ -114,6 +116,8 @@ func (w *Workbook) Write(out io.Writer) error {
114116 } else {115117 version := 4116118 switch {119+ case w.hasSources():120+ version = 7117121 case w.hasTables():118122 version = 6119123 case w.hasPivots():
@@ -149,7 +153,7 @@ func (w *Workbook) Write(out io.Writer) error {
149153 // versions 2 and 3: one sheet, not a notebook, no pivot table nor table,150154 // and no formula naming a sheet.151155 func (w *Workbook) single() bool {152- return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook() && !w.hasTables()156+ return len(w.sheets) == 1 && len(w.crossUsers) == 0 && !w.hasPivots() && !w.hasNotebook() && !w.hasTables() && !w.hasSources()153157 }154158 155159 func jsonString(s string) string {
@@ -374,5 +378,8 @@ func (s *Sheet) read(f fileSheet, old *[]oldRegion) error {
374378 if err := s.readNotebook(f); err != nil {375379 return err376380 }381+ if err := s.checkSourceTab(); err != nil {382+ return err383+ }377384 return s.readView(f.fileView)378385 }
M internal/sheet/linked.go +7 -1
M internal/sheet/pivot.go +11 -2
@@ -334,6 +334,9 @@ func shiftCols[T any](items []T, at func(*T) *int, col func(int) (int, bool)) []
334334 // "Column B" when the header is blank.335335 func (w *Workbook) FieldName(p Pivot, col int) string {336336 if src := w.Lookup(p.Source); src != nil && !p.Lost {337+ if info, ok := src.Source(); ok && col < len(info.Shape.Cols) {338+ return info.Shape.Cols[col]339+ }337340 if h := src.displayText(Addr{Col: col, Row: p.Range.From.Row}); h != "" {338341 return h339342 }
@@ -354,6 +357,12 @@ func (w *Workbook) ValueTitle(p Pivot, v PivotValue) string {
354357 // Sheets: SUM when the column holds a number, COUNTA otherwise.355358 func (w *Workbook) DefaultSummarize(p Pivot, col int) Summarize {356359 if src := w.Lookup(p.Source); src != nil {360+ if info, ok := src.Source(); ok {361+ if col < len(info.Shape.Numeric) && info.Shape.Numeric[col] {362+ return SumBy363+ }364+ return CountABy365+ }357366 last := src.filterData(p.Range).To.Row358367 for row := p.Range.From.Row + 1; row <= last; row++ {359368 if src.Value(Addr{Col: col, Row: row}).Kind == Number {
@@ -369,8 +378,8 @@ func (w *Workbook) DefaultSummarize(p Pivot, col int) Summarize {
369378 // filter's values list does.370379 func (w *Workbook) PivotFilterValues(p Pivot, col int) []FilterValue {371380 src := w.Lookup(p.Source)372- if src == nil || p.Lost {373- return nil381+ if src == nil || p.Lost || src.IsSource() {382+ return nil // a source's values are too many to list374383 }375384 var others []PivotFilter376385 var hidden []string
M internal/sheet/pivotcalc.go +26 -53
@@ -158,7 +158,7 @@ type pivotNode struct {
158158 type pivotCalc struct {159159 w *Workbook160160 p *Pivot161- src *Sheet161+ d pivotData // where the source's rows are read: pivotdata.go162162 root *pivotNode163163 cols []*colGroup // the column groups, sorted once gathered164164 colRoot colNode
@@ -169,8 +169,8 @@ type pivotCalc struct {
169169 records int // source rows summarized170170 }171171 172-func newPivotCalc(w *Workbook, p *Pivot, src *Sheet) *pivotCalc {173- c := &pivotCalc{w: w, p: p, src: src, root: &pivotNode{}}172+func newPivotCalc(w *Workbook, p *Pivot, d pivotData) *pivotCalc {173+ c := &pivotCalc{w: w, p: p, d: d, root: &pivotNode{}}174174 for _, v := range p.Values {175175 c.unique = append(c.unique, v.Summarize == CountUniqueBy)176176 c.formats = append(c.formats, c.valueFormat(v))
@@ -187,40 +187,7 @@ func (c *pivotCalc) valueFormat(v PivotValue) Format {
187187 if !summaries[v.Summarize].keepsFormat {188188 return Format{}189189 }190- return c.columnFormat(v.Col)191-}192-193-// columnFormat is the number format of column col's data in the source194-// range: the column's own when it has one, else the one most of its195-// numbers show in, ties going to the one met first. So one cell typed196-// differently ("$9,000" among "$2.50"s) doesn't restyle every result,197-// as in Sheets.198-func (c *pivotCalc) columnFormat(col int) Format {199- if f := c.src.lines.cols[col].Format; !f.IsZero() {200- return f201- }202- counts := map[Format]int{}203- var seen []Format // in the order first met204- r := c.p.Range205- for row, ok := c.src.cells.filled.nextRow(col, r.From.Row+1, 1); ok && row <= r.To.Row; row, ok = c.src.cells.filled.nextRow(col, row+1, 1) {206- a := Addr{Col: col, Row: row}207- if c.src.Value(a).Kind != Number {208- continue209- }210- f := c.src.DisplayFormat(a)211- if counts[f] == 0 {212- seen = append(seen, f)213- }214- counts[f]++215- }216- var best Format217- most := 0218- for _, f := range seen {219- if counts[f] > most {220- best, most = f, counts[f]221- }222- }223- return best190+ return c.d.colFormat(v.Col, c.p.Range)224191 }225192 226193 // gather reads the source rows the filters let through, skipping rows
@@ -228,24 +195,30 @@ func (c *pivotCalc) columnFormat(col int) Format {
228195 func (c *pivotCalc) gather() {229196 tests := pivotTests(c.p.Filters)230197 r := c.p.Range231- last := c.src.filterData(r).To.Row198+ last := c.d.lastRow(r)232199 for row := r.From.Row + 1; row <= last; row++ {233- if c.src.rowBlank(row, r) || !c.src.rowPasses(row, tests) {234- continue235- }236- c.records++237- ids := c.colGroupsOf(row)238- n := c.root239- c.add(n, ids, row)240- for _, g := range c.p.Rows {241- n = n.child(c.src, Addr{Col: g.Col, Row: row})242- c.add(n, ids, row)243- }200+ c.gatherRow(row, tests)244201 }245202 c.sortRows(c.root, 0)246203 c.sortCols()247204 }248205 206+// gatherRow reads row into its groups, unless it is blank across the207+// range or the filters leave it out.208+func (c *pivotCalc) gatherRow(row int, tests []colTest) {209+ if c.d.blankRow(row, c.p.Range) || !c.d.passes(row, tests) {210+ return211+ }212+ c.records++213+ ids := c.colGroupsOf(row)214+ n := c.root215+ c.add(n, ids, row)216+ for _, g := range c.p.Rows {217+ n = n.child(c.d, Addr{Col: g.Col, Row: row})218+ c.add(n, ids, row)219+ }220+}221+249222 // rowBlank reports whether row is blank across r's columns, looking at250223 // the columns that hold anything.251224 func (s *Sheet) rowBlank(row int, r Rect) bool {
@@ -277,8 +250,8 @@ func pivotTests(fs []PivotFilter) []colTest {
277250 }278251 279252 // child returns the group of n for the value at a, adding it if new.280-func (n *pivotNode) child(src *Sheet, a Addr) *pivotNode {281- v := src.Value(a)253+func (n *pivotNode) child(d pivotData, a Addr) *pivotNode {254+ v := d.value(a)282255 k := keyOf(v, true)283256 if kid, ok := n.byKey[k]; ok {284257 return kid
@@ -286,7 +259,7 @@ func (n *pivotNode) child(src *Sheet, a Addr) *pivotNode {
286259 if n.byKey == nil {287260 n.byKey = map[groupKey]*pivotNode{}288261 }289- kid := &pivotNode{label: v, format: src.DisplayFormat(a)}262+ kid := &pivotNode{label: v, format: d.format(a)}290263 n.byKey[k] = kid291264 n.kids = append(n.kids, kid)292265 return kid
@@ -315,7 +288,7 @@ func (c *pivotCalc) addTo(n *pivotNode, id, row int) {
315288 n.acc[id] = accs316289 }317290 for i, v := range c.p.Values {318- accs[i].add(c.src.Value(Addr{Col: v.Col, Row: row}), c.unique[i])291+ accs[i].add(c.d.value(Addr{Col: v.Col, Row: row}), c.unique[i])319292 }320293 }321294
M internal/sheet/pivotcols.go +3 -3
A internal/sheet/pivotdata.go +194 -0
@@ -0,0 +1,194 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+)7+8+// Where a pivot table reads its source's rows: a sheet's cells, or a9+// linked source's rows as they stream past (source.go), which a pivot10+// over a source gathers in the background (GatherPivot) and lays out11+// once the host answers.12+13+// pivotData is a pivot's source, read a row at a time.14+type pivotData interface {15+ value(a Addr) Value16+ // format is what the value at a shows in.17+ format(a Addr) Format18+ // colFormat is the number format of column col's data in r.19+ colFormat(col int, r Rect) Format20+ // lastRow is r's last row that may hold data.21+ lastRow(r Rect) int22+ // blankRow reports whether row is blank across r's columns.23+ blankRow(row int, r Rect) bool24+ // passes reports whether row meets the filters' tests.25+ passes(row int, tests []colTest) bool26+}27+28+// sheetData is a sheet's cells as a pivot's source.29+type sheetData struct{ s *Sheet }30+31+func (d sheetData) value(a Addr) Value { return d.s.Value(a) }32+func (d sheetData) format(a Addr) Format { return d.s.DisplayFormat(a) }33+func (d sheetData) lastRow(r Rect) int { return d.s.filterData(r).To.Row }34+func (d sheetData) blankRow(row int, r Rect) bool { return d.s.rowBlank(row, r) }35+func (d sheetData) passes(row int, tests []colTest) bool { return d.s.rowPasses(row, tests) }36+37+// colFormat is the column's own format when it has one, else the one38+// most of its numbers show in, ties going to the one met first. So one39+// cell typed differently ("$9,000" among "$2.50"s) doesn't restyle40+// every result, as in Sheets.41+func (d sheetData) colFormat(col int, r Rect) Format {42+ s := d.s43+ if f := s.lines.cols[col].Format; !f.IsZero() {44+ return f45+ }46+ counts := map[Format]int{}47+ var seen []Format // in the order first met48+ for row, ok := s.cells.filled.nextRow(col, r.From.Row+1, 1); ok && row <= r.To.Row; row, ok = s.cells.filled.nextRow(col, row+1, 1) {49+ a := Addr{Col: col, Row: row}50+ if s.Value(a).Kind != Number {51+ continue52+ }53+ f := s.DisplayFormat(a)54+ if counts[f] == 0 {55+ seen = append(seen, f)56+ }57+ counts[f]++58+ }59+ var best Format60+ most := 061+ for _, f := range seen {62+ if counts[f] > most {63+ best, most = f, counts[f]64+ }65+ }66+ return best67+}68+69+// rowData is a source's rows as a pivot's source: the row streaming70+// past, its values by column.71+type rowData struct {72+ shape SourceShape73+ row []LiveCell74+}75+76+func (d *rowData) cell(col int) LiveCell {77+ if col < len(d.row) {78+ return d.row[col]79+ }80+ return LiveCell{}81+}82+83+func (d *rowData) value(a Addr) Value { return d.cell(a.Col).V }84+85+func (d *rowData) format(a Addr) Format {86+ if c := d.cell(a.Col); !c.F.IsZero() {87+ return c.F88+ }89+ return d.colFormat(a.Col, Rect{})90+}91+92+func (d *rowData) colFormat(col int, _ Rect) Format {93+ if col < len(d.shape.Formats) {94+ return d.shape.Formats[col]95+ }96+ return Format{}97+}98+99+func (d *rowData) lastRow(Rect) int { return d.shape.Rows }100+101+func (d *rowData) blankRow(int, Rect) bool {102+ for _, c := range d.row {103+ if c.V.Kind != Empty {104+ return false105+ }106+ }107+ return true108+}109+110+func (d *rowData) passes(_ int, tests []colTest) bool {111+ for _, t := range tests {112+ c := d.cell(t.col)113+ f := c.F114+ if f.IsZero() {115+ f = d.colFormat(t.col, Rect{})116+ }117+ shown := ""118+ if c.V.Kind != Empty {119+ shown = FormatText(c.V, f)120+ }121+ if t.hidden[shown] || !t.cond(c.V, shown) {122+ return false123+ }124+ }125+ return true126+}127+128+// PivotGroups is a pivot table's source rows gathered into groups, as129+// GatherPivot leaves them for the layout.130+type PivotGroups struct {131+ calc *pivotCalc132+}133+134+// Records counts the source rows the groups summarize.135+func (g *PivotGroups) Records() int { return g.calc.records }136+137+// PivotCols are the source columns a pivot reads, for a source to138+// stream no others.139+func PivotCols(p Pivot) []int { return p.fieldCols() }140+141+// GatherPivot gathers a pivot's groups from a linked source of shape142+// shape, whose rows scan streams: it calls its argument with each row143+// (counting from 0, under the header) and its values by column (those144+// PivotCols names, the rest blank), stopping when told. It touches no145+// workbook, so it runs in the background.146+func GatherPivot(p Pivot, shape SourceShape, scan func(each func(row int, vals []LiveCell) bool) error) (*PivotGroups, error) {147+ d := &rowData{shape: shape}148+ c := newPivotCalc(nil, &p, d)149+ tests := pivotTests(p.Filters)150+ err := scan(func(row int, vals []LiveCell) bool {151+ d.row = vals152+ c.gatherRow(row+1, tests)153+ return true154+ })155+ if err != nil {156+ return nil, err157+ }158+ d.row = nil159+ c.sortRows(c.root, 0)160+ c.sortCols()161+ return &PivotGroups{calc: c}, nil162+}163+164+// pivotKey identifies a pivot's definition, for the answers a source's165+// host keeps.166+func pivotKey(p Pivot) string { return fmt.Sprintf("%+v", p) }167+168+// sourcePivot is s's pivot over the linked source reg, as its host has169+// gathered it: nil while it's on its way, s then waiting for it.170+func (w *Workbook) sourcePivot(s *Sheet, reg Region, p *Pivot) (*pivotCalc, error) {171+ if w.sources == nil {172+ return nil, errors.New("Nothing reads linked sources here")173+ }174+ q := SourceQuestion{Kind: AskPivot, Source: reg.Name, Pivot: *p}175+ ans, ok := w.sources.Answer(q)176+ switch {177+ case !ok:178+ k := q.Key()179+ if w.src.pivots == nil {180+ w.src.pivots = map[string][]*Sheet{}181+ }182+ w.src.pivots[k] = append(w.src.pivots[k], s)183+ return nil, nil184+ case ans.Pivot == nil:185+ why := ans.Why186+ if why == "" {187+ why = reg.Name + " can't be read"188+ }189+ return nil, errors.New(why)190+ }191+ c := *ans.Pivot.calc // the host keeps its own for the next layout192+ c.w, c.p = w, p193+ return &c, nil194+}
M internal/sheet/pivotlayout.go +13 -2
M internal/sheet/rangememo.go +6 -0
M internal/sheet/recalc.go +10 -0
M internal/sheet/region.go +4 -2
M internal/sheet/regionfile.go +11 -1
@@ -30,6 +30,9 @@ type fileRegion struct {
3030 Table string `json:"table,omitempty"`3131 Query string `json:"query,omitempty"`3232 Window int `json:"window,omitempty"`33+ // A linked source's: see sourcefile.go.34+ Paged bool `json:"paged,omitempty"`35+ Order *fileSourceOrder `json:"order,omitempty"`3336 // A command region's, read to convert it.3437 Command string `json:"command,omitempty"`3538 Input string `json:"input,omitempty"`
@@ -53,7 +56,8 @@ func (s *Sheet) writeRegionDefs(b *bufio.Writer, indent string) error {
5356 b.WriteString(",\n" + indent + `"regions": [`)5457 for i, r := range s.regions.list {5558 fr := fileRegion{Name: r.Name, At: r.At.String(), Output: r.Output,56- Path: r.File.Path, Format: r.File.Format, Table: r.File.Table, Query: r.File.Query, Window: r.File.Window}59+ Path: r.File.Path, Format: r.File.Format, Table: r.File.Table, Query: r.File.Query, Window: r.File.Window,60+ Paged: r.File.Paged, Order: encodeOrder(r.File.Order)}5761 raw, err := json.Marshal(fr)5862 if err != nil {5963 return err
@@ -83,6 +87,12 @@ func (s *Sheet) readRegions(frs []fileRegion, old *[]oldRegion) error {
8387 }8488 r := Region{Name: fr.Name, At: at, Output: fr.Output,8589 File: LinkSource{Path: fr.Path, Format: fr.Format, Table: fr.Table, Query: fr.Query, Window: fr.Window}}90+ if fr.Paged || fr.Order != nil {91+ if err := s.readSource(fr, r); err != nil {92+ return fmt.Errorf("region %s: %w", fr.Name, err)93+ }94+ continue95+ }8696 switch {8797 case fr.Window < 0:8898 return fmt.Errorf("region %s: invalid window %d", fr.Name, fr.Window)
M internal/sheet/regionshape.go +10 -1
M internal/sheet/regionwrite.go +1 -1
M internal/sheet/sheet.go +3 -0
A internal/sheet/source.go +241 -0
@@ -0,0 +1,241 @@
1+package sheet2+3+import (4+ "errors"5+ "slices"6+ "strconv"7+)8+9+// Linked sources: a Parquet file or a SQLite table or query linked as a10+// read-only table too big for any grid. A source is a tab of its own, a11+// paged sheet, holding no cells: its table is one region at A1 whose12+// definition (a LinkSource with Paged set) names what it reads, so it13+// is a table as a linked file's is, sales[amount] and nu.sales, but its14+// rows are never written into cells. The UI shows a window of them as15+// it scrolls; formulas and pivot tables ask the workbook's SourceHost16+// (sourceask.go), which answers them by streaming the source in the17+// background. Its table's rows may go past the grid's last row.18+19+// SourceShape is what a source is, as its host finds on opening it:20+// its columns' names, the formats their types show in, which hold21+// numbers, and how many rows it has under the header.22+type SourceShape struct {23+ Cols []string24+ Formats []Format25+ Numeric []bool26+ Rows int27+}28+29+// SourceSort is a column a source's view is sorted by.30+type SourceSort struct {31+ Col int32+ Desc bool33+}34+35+// SourceFilter is a condition a source's view shows the rows meeting,36+// on a column.37+type SourceFilter struct {38+ Col int39+ Cond Condition40+}41+42+// SourceOrder is how a source's tab sorts and filters the rows it43+// shows. Formulas read the source in its own order, every row, as they44+// read a sheet whatever its filter hides.45+type SourceOrder struct {46+ Sort []SourceSort47+ Filter []SourceFilter48+}49+50+// IsZero reports whether the order is the source's own, every row.51+func (o *SourceOrder) IsZero() bool { return o == nil || len(o.Sort) == 0 && len(o.Filter) == 0 }52+53+// sourceMeta is what a source's host found of it, never undone.54+type sourceMeta struct {55+ shape SourceShape56+ known bool // shape holds what the host found57+ err string // why the source can't be read, or ""58+}59+60+// Errors of sources.61+var (62+ // ErrSourceEdit is what typing into a source's tab says.63+ ErrSourceEdit = errors.New("A linked source is read-only: its rows are the file's")64+ // ErrNoSource is returned for a source the workbook doesn't hold.65+ ErrNoSource = errors.New("There's no linked source by that name")66+)67+68+// SourceInfo is a linked source as the UI shows it.69+type SourceInfo struct {70+ // Name names it in formulas (sales[amount], nu.sales) and in the71+ // SourceHost's questions.72+ Name string73+ Sheet *Sheet74+ Source LinkSource75+ Shape SourceShape76+ // Known is set once its host has opened it; Err says why it can't.77+ Known bool78+ Err string79+}80+81+// Source returns the linked source the sheet is the tab of.82+func (s *Sheet) Source() (SourceInfo, bool) {83+ if r, ok := s.pagedRegion(); ok {84+ return s.sourceInfo(r), true85+ }86+ return SourceInfo{}, false87+}88+89+// IsSource reports whether the sheet is a linked source's tab.90+func (s *Sheet) IsSource() bool {91+ _, ok := s.pagedRegion()92+ return ok93+}94+95+func (s *Sheet) sourceInfo(r Region) SourceInfo {96+ info := SourceInfo{Name: r.Name, Sheet: s, Source: r.File}97+ if sm := s.meta(nameKey(r.Name)).src; sm != nil {98+ info.Shape, info.Known, info.Err = sm.shape, sm.known, sm.err99+ }100+ return info101+}102+103+// Sources returns the workbook's linked sources, in the order of their104+// tabs.105+func (w *Workbook) Sources() []SourceInfo {106+ var out []SourceInfo107+ for _, s := range w.sheets {108+ if info, ok := s.Source(); ok {109+ out = append(out, info)110+ }111+ }112+ return out113+}114+115+// LookupSource finds a linked source by name, ignoring case.116+func (w *Workbook) LookupSource(name string) (SourceInfo, bool) {117+ if s, r, ok := w.Region(name); ok && r.File.Paged {118+ return s.sourceInfo(r), true119+ }120+ return SourceInfo{}, false121+}122+123+// hasSources reports whether the workbook holds a linked source.124+func (w *Workbook) hasSources() bool {125+ return slices.ContainsFunc(w.sheets, (*Sheet).IsSource)126+}127+128+// AddSource adds a tab linking src as a source named name (a free name129+// made from the file's when ""), after the sheet after, as one undo130+// step. The tab and the table share the name. Its host opens it.131+func (w *Workbook) AddSource(name string, src LinkSource, after *Sheet) (*Sheet, error) {132+ if name == "" {133+ name = w.sourceName(src.Path)134+ }135+ if err := w.checkRegionName(name, false); err != nil {136+ return nil, err137+ }138+ if err := w.checkName(nil, name); err != nil {139+ return nil, err140+ }141+ src.Paged = true142+ s := w.newSheet(name)143+ s.regions.list = []Region{{Name: name, File: src}}144+ at := len(w.sheets)145+ if i := w.Index(after); i >= 0 {146+ at = i + 1147+ }148+ w.change(s, "link source "+name, Rect{}, func() {149+ w.recordSheets()150+ w.insert(s, at)151+ })152+ return s, nil153+}154+155+// SetSourceOrder changes how a source's tab sorts and filters its rows,156+// as one undo step; o nil shows the source's own order.157+func (w *Workbook) SetSourceOrder(name string, o *SourceOrder) error {158+ s, r, ok := w.Region(name)159+ if !ok || !r.File.Paged {160+ return ErrNoSource161+ }162+ if o.IsZero() {163+ o = nil164+ }165+ if o == nil && r.File.Order == nil {166+ return nil167+ }168+ k := nameKey(r.Name)169+ s.change("order "+r.Name, Rect{}, func() {170+ st := s.regionsCopy()171+ st.list[s.regionIndex(k)].File.Order = o172+ s.putRegions(st)173+ })174+ return nil175+}176+177+// SetSourceShape records what the host found on opening a source, or178+// why it couldn't (err), and recalculates what reads it. It isn't an179+// edit: the file is what it is.180+func (w *Workbook) SetSourceShape(name string, shape SourceShape, err string) {181+ s, r, ok := w.Region(name)182+ if !ok || !r.File.Paged {183+ return184+ }185+ s.meta(nameKey(r.Name)).src = &sourceMeta{shape: shape, known: err == "", err: err}186+ w.SourceChanged(name)187+}188+189+// SourceChanged recalculates what reads a source, whose file changed:190+// the formulas that asked it something or name its table, and the191+// pivot tables over it. Its host has forgotten its answers.192+func (w *Workbook) SourceChanged(name string) {193+ s, r, ok := w.Region(name)194+ if !ok || !r.File.Paged {195+ return196+ }197+ k := nameKey(r.Name)198+ var changed []loc199+ for _, set := range []map[loc]struct{}{w.src.users[k], w.nameUsers[k], w.nameUsers[nameKey(regionPrefix+k)]} {200+ for l := range set {201+ if l.s.live && l.s.cells.has(l.a) {202+ changed = append(changed, l)203+ }204+ }205+ }206+ delete(w.src.users, k)207+ for _, p := range w.pivotsOn(s) {208+ p.pivot.stale = true209+ }210+ w.recalcFrom(changed, false)211+}212+213+// sourceTable is the table of the paged region r on s, header row214+// included, and whether it is known: its rows may pass the grid's.215+func (s *Sheet) sourceTable(r Region) (Rect, SourceShape, bool) {216+ sm := s.meta(nameKey(r.Name)).src217+ if sm == nil || !sm.known || len(sm.shape.Cols) == 0 {218+ return Rect{}, SourceShape{}, false219+ }220+ return Rect{To: Addr{Col: len(sm.shape.Cols) - 1, Row: sm.shape.Rows}}, sm.shape, true221+}222+223+// sourceErr is why the paged region r can't be read, "" when it can or224+// its host hasn't said yet.225+func (s *Sheet) sourceErr(r Region) string {226+ if sm := s.meta(nameKey(r.Name)).src; sm != nil {227+ return sm.err228+ }229+ return ""230+}231+232+// sourceName is a free name for a source reading the file at path, as233+// both a table and a tab: the file's name, numbered when taken.234+func (w *Workbook) sourceName(path string) string {235+ name := w.linkName(path)236+ base := name237+ for n := 2; w.checkName(nil, name) != nil || w.checkRegionName(name, false) != nil; n++ {238+ name = base + "_" + strconv.Itoa(n)239+ }240+ return name241+}
A internal/sheet/sourceask.go +255 -0
@@ -0,0 +1,255 @@
1+package sheet2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/functions"8+)9+10+// What formulas and pivot tables ask of linked sources (source.go). The11+// engine never reads a file: it describes each question and asks the12+// workbook's SourceHost, which answers from what it keeps and queues13+// the rest, worked out in the background by streaming the source (the14+// UI's; headless answers at once). Until an answer arrives the cell15+// shows Loading…, as a JEV question does, and when answers arrive the16+// UI calls RecalcSourced, which recomputes what waited for them. A17+// source's answers are kept until its file changes (SourceChanged).18+19+// SourceHost answers the questions of a workbook's formulas and pivot20+// tables about its linked sources.21+type SourceHost interface {22+ // Answer returns the answer to q, or false while it isn't known,23+ // queuing q to be worked out.24+ Answer(q SourceQuestion) (SourceAnswer, bool)25+}26+27+// AskKind is what a SourceQuestion asks.28+type AskKind uint829+30+const (31+ // AskCell asks a cell's value: At, on the source's tab.32+ AskCell AskKind = iota33+ // AskRange asks the values of a range, R, as an array: read whole,34+ // so no bigger than the host's budget.35+ AskRange36+ // AskCall asks what a function computes, Call, its ranges named by37+ // their sources' names.38+ AskCall39+ // AskPivot asks a pivot table's groups over the source, Pivot.40+ AskPivot41+)42+43+// SourceQuestion is one question about a linked source. Source is the44+// source's name, the one whose file changing makes the answer stale; a45+// call may read others too, named in its arguments.46+type SourceQuestion struct {47+ Kind AskKind48+ Source string49+ At Addr50+ R Rect51+ Call functions.StreamCall52+ Pivot Pivot53+}54+55+// Key identifies the question by its content.56+func (q SourceQuestion) Key() string {57+ switch q.Kind {58+ case AskCell:59+ return fmt.Sprintf("cell\x1f%s\x1f%d:%d", nameKey(q.Source), q.At.Col, q.At.Row)60+ case AskRange:61+ return fmt.Sprintf("range\x1f%s\x1f%d:%d:%d:%d", nameKey(q.Source), q.R.From.Col, q.R.From.Row, q.R.To.Col, q.R.To.Row)62+ case AskPivot:63+ return "pivot\x1f" + nameKey(q.Source) + "\x1f" + pivotKey(q.Pivot)64+ }65+ return "call\x1f" + q.Call.Key()66+}67+68+// Sources names every source the question reads.69+func (q SourceQuestion) Sources() []string {70+ if q.Kind != AskCall {71+ return []string{q.Source}72+ }73+ var out []string74+ for _, a := range q.Call.Args {75+ if a.Sheet != "" && !containsFold(out, a.Sheet) {76+ out = append(out, a.Sheet)77+ }78+ }79+ return out80+}81+82+func containsFold(list []string, s string) bool {83+ for _, x := range list {84+ if strings.EqualFold(x, s) {85+ return true86+ }87+ }88+ return false89+}90+91+// SourceAnswer is what a SourceQuestion is answered: a value, an array92+// of them, or a pivot table's groups; for an error that needs one, why.93+type SourceAnswer struct {94+ V Value95+ A *functions.Array96+ Why string97+ Pivot *PivotGroups98+}99+100+// SetSources sets what answers the workbook's questions about linked101+// sources, nil for nothing (they then say so), and recomputes what102+// reads them.103+func (w *Workbook) SetSources(h SourceHost) {104+ w.sources = h105+ w.recalc(w.src.all())106+}107+108+// sourceAsks keeps who asked what of linked sources: the formulas that109+// asked each source anything, so its file changing recalculates them,110+// the formulas and pivot sheets waiting for each question's answer, and111+// why each formula whose answer was an error shows it.112+type sourceAsks struct {113+ users map[string]map[loc]struct{} // by source key114+ waiting map[string]map[loc]struct{} // by question key115+ pivots map[string][]*Sheet // by question key116+ why map[loc]string117+}118+119+// all is every formula that asked a source something.120+func (a *sourceAsks) all() []loc {121+ var out []loc122+ for _, set := range a.users {123+ for l := range set {124+ out = append(out, l)125+ }126+ }127+ return out128+}129+130+// errNoSources is what a formula reading a source shows when nothing131+// answers them.132+var errNoSources = ErrNA133+134+// ask looks up the answer to q for the formula being evaluated, noting135+// that it read the sources q reads and, when the answer isn't known,136+// that it waits for it.137+func (w *Workbook) ask(q SourceQuestion) (SourceAnswer, bool) {138+ l := w.evaluating139+ if l.s != nil {140+ for _, src := range q.Sources() {141+ addLoc(&w.src.users, nameKey(src), l)142+ }143+ delete(w.src.why, l)144+ }145+ if w.sources == nil {146+ return SourceAnswer{V: errNoSources, Why: "Nothing reads linked sources here"}, true147+ }148+ ans, ok := w.sources.Answer(q)149+ switch {150+ case !ok && l.s != nil:151+ addLoc(&w.src.waiting, q.Key(), l)152+ case ok && ans.Why != "" && l.s != nil:153+ if w.src.why == nil {154+ w.src.why = map[loc]string{}155+ }156+ w.src.why[l] = ans.Why157+ }158+ return ans, ok159+}160+161+func addLoc(m *map[string]map[loc]struct{}, k string, l loc) {162+ if *m == nil {163+ *m = map[string]map[loc]struct{}{}164+ }165+ if (*m)[k] == nil {166+ (*m)[k] = map[loc]struct{}{}167+ }168+ (*m)[k][l] = struct{}{}169+}170+171+// RecalcSourced recomputes the formulas and pivot tables that were172+// waiting for the answers to the questions with keys, and what depends173+// on them. It doesn't touch the undo history.174+func (w *Workbook) RecalcSourced(keys []string) {175+ var changed []loc176+ for _, k := range keys {177+ for l := range w.src.waiting[k] {178+ if l.s.live && l.s.cells.has(l.a) {179+ changed = append(changed, l)180+ }181+ }182+ delete(w.src.waiting, k)183+ for _, p := range w.src.pivots[k] {184+ if p.live {185+ p.pivot.stale = true186+ }187+ }188+ delete(w.src.pivots, k)189+ }190+ w.recalcFrom(changed, false)191+}192+193+// sourceFailure says why the formula c at l shows an error that a194+// linked source gave it: the answer's reason, or why a source it names195+// can't be read.196+func (s *Sheet) sourceFailure(l loc, c *Cell) string {197+ if why := s.wb.src.why[l]; why != "" {198+ return why199+ }200+ for _, k := range c.names {201+ k = strings.TrimPrefix(k, nameKey(regionPrefix))202+ if info, ok := s.wb.LookupSource(k); ok && info.Err != "" {203+ return info.Name + ": " + info.Err204+ }205+ }206+ return ""207+}208+209+// paged is the paged sheet a reference names from the formula's sheet,210+// with its source, or nil.211+func (rd *reader) paged(sheet string) (*Sheet, Region) {212+ if sheet == "" {213+ return nil, Region{}214+ }215+ t := rd.sheet(sheet)216+ if t == nil {217+ return nil, Region{}218+ }219+ if r, ok := t.pagedRegion(); ok {220+ return t, r221+ }222+ return nil, Region{}223+}224+225+// Paged reports whether a sheet is a linked source's tab.226+func (rd *reader) Paged(sheet string) bool {227+ t, _ := rd.paged(sheet)228+ return t != nil229+}230+231+// Stream answers a call over linked sources, its ranges renamed by232+// their sources' names so the answer stays the same whatever the tabs233+// are called.234+func (rd *reader) Stream(call functions.StreamCall) (Value, *functions.Array) {235+ q := SourceQuestion{Kind: AskCall, Call: call}236+ q.Call.Args = append([]functions.StreamArg(nil), call.Args...)237+ for i, a := range q.Call.Args {238+ if a.Sheet == "" {239+ continue240+ }241+ t, r := rd.paged(a.Sheet)242+ if t == nil {243+ return ErrRef, nil244+ }245+ q.Call.Args[i].Sheet = r.Name246+ if q.Source == "" {247+ q.Source = r.Name248+ }249+ }250+ ans, ok := rd.w.ask(q)251+ if !ok {252+ return Pending, nil253+ }254+ return ans.V, ans.A255+}
A internal/sheet/sourcefile.go +113 -0
@@ -0,0 +1,113 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+)7+8+// A linked source in the file (source.go): its tab is a sheet whose one9+// region is paged, at A1, with no cells. The region keeps what the10+// source reads and how its tab sorts and filters it, never its rows:11+//12+// "regions": [13+// {"name":"sales","at":"A1","path":"sales.parquet","paged":true,14+// "order":{"sort":[{"column":"C","desc":true}],"filter":[{"column":"B","condition":"eq","value":"North"}]}}15+// ]16+//17+// Sources need version 7: a build without them would read the region18+// as a linked file and load the source into cells.19+20+// fileSourceOrder is a source's SourceOrder, columns by letter.21+type fileSourceOrder struct {22+ Sort []fileSourceSort `json:"sort,omitempty"`23+ Filter []fileSourceFilter `json:"filter,omitempty"`24+}25+26+type fileSourceSort struct {27+ Column string `json:"column"`28+ Desc bool `json:"desc,omitempty"`29+}30+31+type fileSourceFilter struct {32+ Column string `json:"column"`33+ Condition string `json:"condition"`34+ Value string `json:"value,omitempty"`35+}36+37+// encodeOrder is o as the file keeps it, nil for the source's own.38+func encodeOrder(o *SourceOrder) *fileSourceOrder {39+ if o.IsZero() {40+ return nil41+ }42+ fo := &fileSourceOrder{}43+ for _, s := range o.Sort {44+ fo.Sort = append(fo.Sort, fileSourceSort{Column: ColName(s.Col), Desc: s.Desc})45+ }46+ for _, f := range o.Filter {47+ fo.Filter = append(fo.Filter, fileSourceFilter{Column: ColName(f.Col), Condition: f.Cond.Op.String(), Value: f.Cond.Arg})48+ }49+ return fo50+}51+52+// decodeOrder reads what encodeOrder wrote.53+func decodeOrder(fo *fileSourceOrder) (*SourceOrder, error) {54+ if fo == nil {55+ return nil, nil56+ }57+ o := &SourceOrder{}58+ for _, s := range fo.Sort {59+ c, ok := ParseCol(s.Column)60+ if !ok {61+ return nil, fmt.Errorf("invalid sort column %q", s.Column)62+ }63+ o.Sort = append(o.Sort, SourceSort{Col: c, Desc: s.Desc})64+ }65+ for _, f := range fo.Filter {66+ c, ok := ParseCol(f.Column)67+ if !ok {68+ return nil, fmt.Errorf("invalid filter column %q", f.Column)69+ }70+ op, ok := ParseCondOp(f.Condition)71+ if !ok || op == CondNone {72+ return nil, fmt.Errorf("invalid filter condition %q", f.Condition)73+ }74+ o.Filter = append(o.Filter, SourceFilter{Col: c, Cond: Condition{Op: op, Arg: f.Value}})75+ }76+ if o.IsZero() {77+ return nil, nil78+ }79+ return o, nil80+}81+82+// readSource reads a linked source's region, r as far as it's read.83+func (s *Sheet) readSource(fr fileRegion, r Region) error {84+ switch {85+ case !fr.Paged:86+ return errors.New("only a linked source has an order")87+ case fr.Path == "" || fr.Output || fr.Command != "" || fr.Window != 0:88+ return errors.New("a linked source reads a file, all of it")89+ }90+ o, err := decodeOrder(fr.Order)91+ if err != nil {92+ return err93+ }94+ r.File.Paged, r.File.Order = true, o95+ s.regions.list = append(s.regions.list, r)96+ return nil97+}98+99+// errSourceTab is why a sheet can't be a source's tab as the file has100+// it.101+var errSourceTab = errors.New("a linked source's tab holds its source alone, at A1")102+103+// checkSourceTab checks a sheet read from a file with a paged region:104+// the region is its only one, at A1, and it has no cells.105+func (s *Sheet) checkSourceTab() error {106+ if !s.IsSource() {107+ return nil108+ }109+ if len(s.regions.list) != 1 || s.regions.list[0].At != (Addr{}) || s.cells.len() > 0 || s.regions.notebook {110+ return errSourceTab111+ }112+ return nil113+}
A internal/sheet/sourceread.go +154 -0
@@ -0,0 +1,154 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/functions"4+5+// How the engine's reader reads a linked source's tab (source.go),6+// which holds no cells: its header row from what the host found, and7+// every other cell from the host (sourceask.go). A function given a8+// source's range is asked whole (functions.StreamCall); what reads a9+// range some other way (an operator over it, a function whose other10+// arguments are arrays) is given its values read whole, as long as the11+// host's budget allows.12+13+// pagedRegion is the paged region of t, if t is a source's tab.14+func (t *Sheet) pagedRegion() (Region, bool) {15+ for _, r := range t.regions.list {16+ if r.File.Paged {17+ return r, true18+ }19+ }20+ return Region{}, false21+}22+23+// notOpen is what a source's cells read before its host has opened it:24+// Loading…, or #REF! when it can't.25+func (t *Sheet) notOpen(r Region) Value {26+ if t.sourceErr(r) != "" {27+ return ErrRef28+ }29+ return Pending30+}31+32+// pagedCell is the value of the cell at a of t, the tab of the source33+// r: a column's name in the header row, or what the host answers.34+func (rd *reader) pagedCell(t *Sheet, r Region, a Addr) Value {35+ tbl, shape, ok := t.sourceTable(r)36+ switch {37+ case !ok:38+ if l := rd.w.evaluating; l.s != nil {39+ addLoc(&rd.w.src.users, nameKey(r.Name), l)40+ }41+ return t.notOpen(r)42+ case !tbl.Contains(a):43+ return Value{}44+ case a.Row == 0:45+ return Value{Kind: Text, Str: shape.Cols[a.Col]}46+ }47+ ans, ok := rd.w.ask(SourceQuestion{Kind: AskCell, Source: r.Name, At: a})48+ if !ok {49+ return Pending50+ }51+ return ans.V52+}53+54+// pagedRange is the part of r on t, the tab of the source reg, that its55+// table holds, and its values as an array: nil with an error when they56+// can't be had (yet), nil without one when the part is empty.57+func (rd *reader) pagedRange(t *Sheet, reg Region, r Rect) (Rect, *functions.Array, *Value) {58+ tbl, _, ok := t.sourceTable(reg)59+ if !ok {60+ v := t.notOpen(reg)61+ return Rect{}, nil, &v62+ }63+ part, ok := intersectRect(tbl, r)64+ if !ok {65+ return Rect{}, nil, nil66+ }67+ ans, known := rd.w.ask(SourceQuestion{Kind: AskRange, Source: reg.Name, R: part})68+ switch {69+ case !known:70+ return Rect{}, nil, &Pending71+ case ans.A == nil:72+ v := ans.V73+ if v.Kind != Error {74+ v = ErrValue75+ }76+ return Rect{}, nil, &v77+ }78+ return part, ans.A, nil79+}80+81+// pagedScan is Scan over a source's tab: the cells of r holding82+// something, from from on, read from the range's values.83+func (rd *reader) pagedScan(t *Sheet, reg Region, r Rect, from Addr, addrs []Addr, vals []Value) int {84+ part, arr, err := rd.pagedRange(t, reg, r)85+ if err != nil {86+ if len(addrs) == 0 {87+ return 088+ }89+ addrs[0] = r.From90+ if vals != nil {91+ vals[0] = *err92+ }93+ return 194+ }95+ if arr == nil {96+ return 097+ }98+ if from.Row < part.From.Row {99+ from = part.From100+ }101+ n := 0102+ for row := from.Row; row <= part.To.Row && n < len(addrs); row++ {103+ col := part.From.Col104+ if row == from.Row {105+ col = max(col, from.Col)106+ }107+ for ; col <= part.To.Col && n < len(addrs); col++ {108+ v := arr.At(row-part.From.Row, col-part.From.Col)109+ if v.Kind == Empty {110+ continue111+ }112+ addrs[n] = Addr{Col: col, Row: row}113+ if vals != nil {114+ vals[n] = v115+ if v.Kind == Error {116+ return n + 1117+ }118+ }119+ n++120+ }121+ }122+ return n123+}124+125+// pagedFold is Fold over a source's tab.126+func (rd *reader) pagedFold(t *Sheet, reg Region, r Rect, s functions.Agg) (functions.Agg, *Value) {127+ part, arr, err := rd.pagedRange(t, reg, r)128+ if err != nil {129+ return s, s.Add(*err, false)130+ }131+ if arr == nil {132+ return s, nil133+ }134+ for i := range part.To.Row - part.From.Row + 1 {135+ for j := range part.To.Col - part.From.Col + 1 {136+ if v := arr.At(i, j); v.Kind != Empty {137+ if e := s.Add(v, false); e != nil {138+ return s, e139+ }140+ }141+ }142+ }143+ return s, nil144+}145+146+// pagedBounds is Bounds over a source's tab: the part of r its table147+// holds.148+func (t *Sheet) pagedBounds(reg Region, r Rect) (Rect, bool) {149+ tbl, _, ok := t.sourceTable(reg)150+ if !ok {151+ return Rect{}, false152+ }153+ return intersectRect(tbl, r)154+}
M internal/sheet/tablebind.go +11 -0
@@ -6,6 +6,7 @@ import (
66 "strings"77 88 "github.com/FelineStateMachine/012/internal/formula"9+ "github.com/FelineStateMachine/012/internal/functions"910 )1011 1112 // How formulas read tables (table.go): a structured reference,
@@ -27,6 +28,9 @@ type tableView struct {
2728 r Rect // header row included2829 cols []string // the columns' names2930 ok bool // false for a region with no table shown: #REF!31+ // opening is set on a source whose host hasn't opened it yet: its32+ // references read Loading… until it has.33+ opening bool3034 }3135 3236 // findTable finds the table or region formulas name with the key k.
@@ -39,6 +43,10 @@ func (w *Workbook) findTable(k string) (tableView, bool) {
3943 }4044 for _, s := range w.sheets {4145 if i := s.regionIndex(k); i >= 0 {46+ if reg := s.regions.list[i]; reg.File.Paged {47+ r, shape, ok := s.sourceTable(reg)48+ return tableView{s: s, r: r, cols: shape.Cols, ok: ok, opening: !ok && s.sourceErr(reg) == ""}, true49+ }4250 r, ok := s.RegionTable(s.regions.list[i].Name)4351 if !ok {4452 return tableView{s: s}, true
@@ -110,6 +118,9 @@ func (v tableView) colSpan(t formula.TableRef) (int, int, bool) {
110118 // reference to the formula's own row takes; every other is absolute.111119 func (s *Sheet) bindTable(a Addr, t formula.TableRef, thisRow formula.Abs) Node {112120 v, found := s.wb.findTable(nameKey(t.Table))121+ if found && v.opening {122+ return functions.Const(Pending)123+ }113124 if !found || !v.ok {114125 return formula.RefErr{}115126 }
M internal/sheet/workbook.go +4 -0