cat / 012
clone

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

Sources in the file: version 7, random edits, fixtures of v0.5.0

Dami committed 68d3ceb
commit
68d3ceb24a74d7c64a8aa13408fd04341f1f6787
parent
71a1b8c32141e3ef4b3abc40d2425522ba4873ea
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.0 of 3, Jev's estimate
7 files changed, +319 -5
Mdocs/files/format.md34+++++-
Minternal/sheet/randedit_ops_test.go5+-
Ainternal/sheet/randedit_sources_test.go49++++++++
Minternal/sheet/testdata/fixtures/new/workbook.01220+++-
Ainternal/sheet/testdata/fixtures/v0.5.0/plain.01210++
Ainternal/sheet/testdata/fixtures/v0.5.0/single.01221+++
Ainternal/sheet/testdata/fixtures/v0.5.0/workbook.012185++++++++++++++++++++++++++++++
M docs/files/format.md +33 -1
@@ -7,7 +7,7 @@ sidebar_position: 4
77 88 `.012` files are JSON with one line per cell, keyed by address, so diffs99 read naturally and files merge reasonably in version control. This page10-is the format at version 5, the newest; [Versions](#versions) says what10+is the format at version 7, the newest; [Versions](#versions) says what1111 each version adds and what 012 promises about old files. A cell1212 without formatting is just what was typed; a formatted cell is a small1313 object:
@@ -77,6 +77,7 @@ still load.
7777 | 4 | Several sheets, or a formula naming a sheet | `sheets`, a list; see [Several sheets](#several-sheets) |7878 | 5 | A pivot table | a sheet's `pivot`; see [Pivot tables](#pivot-tables) |7979 | 6 | A table | a sheet's `tables`; see [Tables](#tables) |80+| 7 | A linked source | a sheet whose region is `paged`; see [Linked sources](#linked-sources) |8081 8182 What stays the same from release to release:8283
@@ -241,6 +242,37 @@ earlier versions wrote (with `input`, the range it read as `$in`, and
241242 opens as a code cell of the workbook's notebook, its table sent where it242243 was ([Notebook sheets](../nushell/notebooks.md#notebook-sheets)).243244 245+## Linked sources246+247+A [linked source](sources.md)'s tab is a sheet with no cells whose one248+region, at `A1`, is `paged`: the file keeps what the source reads and249+how its tab orders the rows, never the rows.250+251+```json252+ {253+ "name": "trips",254+ "cells": {},255+ "regions": [256+ {"name":"trips","at":"A1","path":"trips.parquet","paged":true,"order":{"sort":[{"column":"C","desc":true}],"filter":[{"column":"B","condition":"eq","value":"card"}]}}257+ ]258+ }259+```260+261+- `name` is the table formulas read (`trips[fare]`, `nu.trips`); the262+ tab is named the same when linked, and keeps its own name when263+ renamed.264+- `path`, `format`, `table` and `query` are what a linked file's are265+ ([Regions](#regions)).266+- `paged` marks the region as a source, read in place.267+- `order` is how the tab sorts and filters the rows: `sort`, the columns268+ by letter, `desc` for Z to A, and `filter`, each a column by letter269+ with a `condition` and its `value`, as a filter's column has270+ ([Filter](../sheets/sort-filter.md#filter)). Formulas read the271+ source in its own order whatever the tab shows.272+273+Sources need version 7: a build without them would read the region as274+a linked file and load the whole source into cells.275+244276 ## Notebooks245277 246278 A notebook tab is a sheet with `"tab": "notebook"`, `"reactive": true`
M internal/sheet/randedit_ops_test.go +3 -2
A internal/sheet/randedit_sources_test.go +49 -0
@@ -0,0 +1,49 @@
1+package sheet2+3+import "fmt"4+5+// Linked sources among the random edits (randedit_test.go): linked,6+// ordered, their tabs renamed and deleted, and formulas reading them,7+// so the tabs, their order in the file and the formulas naming them8+// meet every other edit. Nothing answers the workbook's questions about9+// them, as a file opened without a host: what reads them stays Loading…10+// through undo, redo and reopening alike.11+12+func init() { randEdits = append(randEdits, (*randBook).editSource) }13+14+// randSources are the names the edits give sources.15+var randSources = []string{"big", "huge"}16+17+func (r *randBook) editSource(e *edits) string {18+ w := r.wb19+ name := randSources[e.n(len(randSources))]20+ switch e.n(5) {21+ case 0:22+ _, err := w.AddSource(name, LinkSource{Path: name + ".parquet"}, r.sheet(e))23+ return fmt.Sprintf("link source %s (%v)", name, err)24+ case 1:25+ o := &SourceOrder{}26+ if e.n(2) == 0 {27+ o.Sort = []SourceSort{{Col: e.n(3), Desc: e.n(2) == 0}}28+ }29+ if e.n(2) == 0 {30+ o.Filter = []SourceFilter{{Col: e.n(3), Cond: Condition{Op: CondGreater, Arg: fmt.Sprint(e.n(9))}}}31+ }32+ err := w.SetSourceOrder(name, o)33+ return fmt.Sprintf("order %s %+v (%v)", name, o, err)34+ case 2:35+ info, ok := w.LookupSource(name)36+ if !ok {37+ return "no source " + name38+ }39+ err := w.DeleteSheet(info.Sheet)40+ return fmt.Sprintf("delete source %s (%v)", name, err)41+ }42+ s, a := r.sheet(e), e.addr()43+ in := []string{44+ "=SUM(" + name + "[amount])", `=COUNTIF(` + name + `[region],"x")`, "=ROWS(nu." + name + ")",45+ "=" + name + "!B3", "=IFERROR(XLOOKUP(1," + name + "[id]," + name + "[amount]),0)",46+ }[e.n(5)]47+ err := s.Set(a, in)48+ return fmt.Sprintf("%s!%s = %q (%v)", s.name, a, in, err)49+}
M internal/sheet/testdata/fixtures/new/workbook.012 +18 -2
A internal/sheet/testdata/fixtures/v0.5.0/plain.012 +10 -0
A internal/sheet/testdata/fixtures/v0.5.0/single.012 +21 -0
A internal/sheet/testdata/fixtures/v0.5.0/workbook.012 +185 -0
@@ -0,0 +1,185 @@
1+{2+ "version": 7,3+ "names": {"Lost": "#REF!", "Rate": "Lists!B1", "Units": "Sales!C2:C9"},4+ "macroOrigin": "fixture-machine",5+ "macros": [6+ {"name": "Bold header", "key": "1", "api": 1, "source": "select(\"A1:E1\")\nrun(\"format.bold\")\n"},7+ {"name": "Fill totals", "api": 1, "source": "set(\"F2\", \"=SUM(C2:C9)\")\n"}8+ ],9+ "sheets": [10+ {11+ "name": "Sales",12+ "widths": {"A": 12, "E": 14},13+ "heights": {"1": 2, "12": 3},14+ "lines": {"A:XFD": {"italic":true}, "D:D": {"format":"currency","decimals":2}, "1:1": {"bold":true,"align":"center","borders":{"bottom":"double"}}},15+ "freeze": {"rows":1,"cols":1},16+ "filter": {"range":"A1:E9","columns":{"B":{"hidden":["West"]},"C":{"condition":"gt","value":"2"}}},17+ "protected": [{"range":"E2:E9","description":"Totals"}],18+ "merges": ["A11:C11","A12:A13"],19+ "cells": {20+ "A1": "Date",21+ "B1": "Region",22+ "C1": "Units",23+ "D1": "Price",24+ "E1": "Total",25+ "G1": "Share",26+ "H1": "Regions",27+ "I1": "Scaled",28+ "J1": {"input":"007","text":true},29+ "A2": {"input":"2026-01-05","format":"date"},30+ "B2": "North",31+ "C2": "3",32+ "D2": "12.5",33+ "E2": {"input":"=C2*D2","format":"currency","decimals":2,"own":true},34+ "G2": {"input":"=C2/SUM(Units)","format":"percent","decimals":1},35+ "H2": "=SORT(UNIQUE(B2:B9))",36+ "I2": "=LET(r, Rate, MAP(C2:C4, LAMBDA(x, x*r)))",37+ "J2": {"input":"=1+1","text":false,"format":"text"},38+ "A3": {"input":"2026-01-12","format":"date"},39+ "B3": {"input":"South","note":"Includes returns"},40+ "C3": "5",41+ "D3": "9.75",42+ "E3": "=C3*D3",43+ "G3": {"input":"=C3/SUM(Units)","format":"custom","pattern":"0.00%;[Red]-0.00%"},44+ "A4": {"input":"2026-02-02","format":"date"},45+ "B4": "West",46+ "C4": "1",47+ "D4": "40",48+ "E4": "=C4*D4",49+ "A5": {"input":"2026-02-16","format":"date"},50+ "B5": "North",51+ "C5": "7",52+ "D5": "12.5",53+ "E5": "=C5*D5",54+ "A6": {"input":"2026-03-01","format":"date"},55+ "B6": "East",56+ "C6": {"input":"2","bold":true,"italic":true,"underline":true,"strikethrough":true},57+ "D6": "22",58+ "E6": "=C6*D6",59+ "A7": {"input":"2026-03-09","format":"date"},60+ "B7": "South",61+ "C7": {"input":"4","align":"left","valign":"top"},62+ "D7": "9.75",63+ "E7": "=C7*D7",64+ "A8": {"input":"2026-03-20","format":"date"},65+ "B8": "East",66+ "C8": {"input":"6","borders":{"top":"thin","bottom":"double","left":"thick","right":"thin","bottomColor":"red","leftColor":"blue"}},67+ "D8": "22",68+ "E8": "=C8*D8",69+ "A9": {"input":"2026-03-31","format":"date"},70+ "B9": "West",71+ "C9": "8",72+ "D9": "40",73+ "E9": "=C9*D9",74+ "A11": {"input":"A long title merged across three columns","align":"center","wrap":"wrap"},75+ "A12": {"input":"Tall","valign":"middle"},76+ "B12": {"input":"clipped text that does not spill over","wrap":"clip"},77+ "C12": "TRUE",78+ "D12": "=IFERROR(1/0, \"none\")",79+ "E12": {"input":"=Lists!B1*2","format":"number","decimals":3},80+ "F12": "'00123",81+ "F13": "=HYPERLINK(\"https://example.com\", \"site\")"82+ },83+ "charts": [84+ {"type":"column","data":"A1:C9","at":"K2","width":40,"height":12,"header":true,"labels":true,"title":"Units by date"},85+ {"type":"line","data":"C1:E9","at":"K16","width":36,"height":10,"header":true,"stack":"stacked","trend":true,"min":0,"max":400,"gridlines":false,"legend":"none"},86+ {"type":"pie","data":"B1:C5","at":"K28","width":30,"height":10,"header":true,"labels":true,"legend":"right"},87+ {"type":"scatter","data":"C1:D9","at":"K40","width":30,"height":10,"header":true,"trend":true,"log":true},88+ {"type":"bar","data":"B1:C9","at":"K52","width":30,"height":10,"header":true,"stack":"percent"},89+ {"type":"area","data":"C1:D9","at":"K64","width":30,"height":10,"byRow":true,"header":true}90+ ],91+ "conditionalFormats": [92+ {"ranges":"C2:C9","condition":"gt","values":["5"],"text":"red","bold":true},93+ {"ranges":"B2:B9","condition":"contains","values":["th"],"fill":"yellow","italic":true},94+ {"ranges":"A2:A9","condition":"date_is","values":["this week"],"underline":true},95+ {"ranges":"E2:E9","condition":"formula","values":["=$C2\u003e4"],"text":"green","strikethrough":true},96+ {"ranges":"C2:C9","condition":"between","values":["2","4"],"fill":"cyan"},97+ {"ranges":"C2:C9","condition":"top","values":["3"],"fill":"magenta"},98+ {"ranges":"D2:D9","condition":"duplicate","text":"blue"},99+ {"ranges":"C2:C9","condition":"above_average","bold":true},100+ {"ranges":"D2:D9","scale":[{"type":"min","color":"green"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"red"}]},101+ {"ranges":"E2:E9","dataBar":{"color":"blue","min":{"type":"min"},"max":{"type":"num","value":"400"},"barOnly":true}},102+ {"ranges":"C2:C9,D2:D9","iconSet":{"icons":"arrows","points":[{"type":"percent","value":"33"},{"type":"percent","value":"67"}],"reverse":true}}103+ ],104+ "validations": [105+ {"ranges":"B2:B9","criteria":"list","items":["North","South","East","West"],"help":"Pick a region","display":"chip"},106+ {"ranges":"C2:C9","criteria":"number","condition":"between","values":["0","80"],"reject":true},107+ {"ranges":"A2:A9","criteria":"date","condition":"gt","values":["2025-12-31"]},108+ {"ranges":"C12","criteria":"checkbox","items":["TRUE","FALSE"]},109+ {"ranges":"F2:F9","criteria":"range","source":"Lists!A1:A4","display":"plain"},110+ {"ranges":"G5:G9","criteria":"length","condition":"le","values":["10"]},111+ {"ranges":"H12","criteria":"formula","values":["=H12\u003c\u003e\"\""]}112+ ]113+ },114+ {115+ "name": "Lists",116+ "hidden": true,117+ "protected": [{"sheet":true}],118+ "tables": [{"name":"Targets","range":"D1:E3","columns":["Region","Target"],"banded":true,"header":true}],119+ "cells": {120+ "A1": "North",121+ "B1": {"input":"1.5","note":"Rate used by Sales!I2"},122+ "D1": "Region",123+ "E1": "Target",124+ "F1": "=SUM(Targets[Target])+ROWS(Targets[#All])",125+ "A2": "South",126+ "D2": "North",127+ "E2": "20",128+ "F2": "=Targets[@Target]*2",129+ "A3": "East",130+ "D3": "South",131+ "E3": "=E2/2",132+ "F3": "=COUNTA(Targets[[#Headers],[Region]:[Target]])",133+ "A4": "West"134+ }135+ },136+ {137+ "name": "Pivot Table 1",138+ "cells": {},139+ "pivot": {"source":"Sales!A1:E9","rows":[{"column":"B"},{"column":"A","order":"desc","sortBy":1}],"columns":[{"column":"C"}],"values":[{"column":"E","summarize":"sum"},{"column":"C","summarize":"counta","showAs":"percent_of_total","name":"Share"}],"filters":[{"column":"B","hidden":["West"]}],"rowTotals":true,"columnTotals":false}140+ },141+ {142+ "name": "Data",143+ "widths": {"A": 20},144+ "cells": {145+ "A20": "=SUM(nu.files)",146+ "A21": "=SUM(trips[fare])",147+ "A22": "=COUNTIFS(events[kind],\"click\",events[ms],\"\u003e100\")"148+ },149+ "regions": [150+ {"name":"files","at":"A1","output":true},151+ {"name":"big","at":"A8","output":true},152+ {"name":"app","at":"F2","path":"logs/app.csv","window":500},153+ {"name":"db","at":"F20","path":"data.sqlite","format":"SQLite","table":"orders"},154+ {"name":"q","at":"M20","path":"data.sqlite","format":"SQLite","query":"select * from orders limit 5"}155+ ]156+ },157+ {158+ "name": "trips",159+ "cells": {},160+ "regions": [161+ {"name":"trips","at":"A1","path":"trips.parquet","paged":true,"order":{"sort":[{"column":"C","desc":true}],"filter":[{"column":"B","condition":"eq","value":"card"},{"column":"D","condition":"gt","value":"10"}]}}162+ ]163+ },164+ {165+ "name": "events",166+ "cells": {},167+ "regions": [168+ {"name":"events","at":"A1","path":"events.sqlite","format":"SQLite","query":"select * from events where day \u003e 0","paged":true}169+ ]170+ },171+ {172+ "name": "Notebook",173+ "cells": {},174+ "tab": "notebook",175+ "reactive": true,176+ "notebookCells": [177+ {"kind":"note","source":"# Files\nWhat the folder holds."},178+ {"source":"files = ls","output":"[[name, size]; [go.mod, 812b], [README.md, 4kb]]"},179+ {"source":"big = $files | where size \u003e 1kb","output":"[[name, size]; [README.md, 4kb]]","note":"2 rows left out"},180+ {"source":"$files | nope","error":"Command `nope` not found","detail":"help: check the spelling"},181+ {"source":"ls **/*","unsaved":true}182+ ]183+ }184+ ]185+}