cat / 012
clone

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

Import and export CSV, TSV, XLSX, SQLite, Parquet and Lotus .wk1

Dami committed a9b8b20
commit
a9b8b20ecea64560b301c8b31c1a81437c9f6a1c
parent
cebe46425e784fa56867e48c9d3e42bf17dd3372
author
Dami <dami.etoile@gmail.com>
20 files changed, +3811 -4
MMakefile2+
Mgo.mod30++-
Mgo.sum106++++++++-
Ainternal/fileio/builder.go184+++++++++++++
Ainternal/fileio/csv.go278++++++++++++++++++++
Ainternal/fileio/csv_test.go245++++++++++++++++++
Ainternal/fileio/fileio.go221++++++++++++++++
Ainternal/fileio/formula.go129+++++++++
Ainternal/fileio/numfmt.go210+++++++++++++++
Ainternal/fileio/parquet.go231+++++++++++++++++
Ainternal/fileio/parquet_test.go75+++++
Ainternal/fileio/snapshot.go73+++++
Ainternal/fileio/sqlite.go411++++++++++++++++++++++++++++++
Ainternal/fileio/sqlite_test.go179+++++++++++++
Ainternal/fileio/testdata/fuzz/FuzzReadWK1/6f51e843b7351fb52+
Ainternal/fileio/wk1.go311+++++++++++++++++++++++
Ainternal/fileio/wk1_test.go236+++++++++++++++++
Ainternal/fileio/wk1formula.go361++++++++++++++++++++++++++
Ainternal/fileio/xlsx.go293+++++++++++++++++++++
Ainternal/fileio/xlsx_test.go238+++++++++++++++++
M Makefile +2 -0
M go.mod +28 -2
@@ -5,24 +5,50 @@ go 1.27.1
55 require (66 charm.land/bubbletea/v2 v2.0.1077 charm.land/lipgloss/v2 v2.0.68+ github.com/FelineStateMachine/typesafe-go v0.2.09+ github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886810 github.com/charmbracelet/x/ansi v0.11.811+ github.com/parquet-go/parquet-go v0.32.0912 github.com/sahilm/fuzzy v0.1.313+ github.com/xuri/excelize/v2 v2.11.014+ golang.org/x/text v0.38.015+ modernc.org/sqlite v1.59.01016 )1117 1218 require (13- github.com/FelineStateMachine/typesafe-go v0.2.0 // indirect19+ github.com/andybalholm/brotli v1.1.1 // indirect1420 github.com/charmbracelet/colorprofile v0.4.3 // indirect15- github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect1621 github.com/charmbracelet/x/term v0.2.2 // indirect1722 github.com/charmbracelet/x/termios v0.1.1 // indirect1823 github.com/charmbracelet/x/windows v0.2.2 // indirect1924 github.com/clipperhouse/displaywidth v0.11.0 // indirect2025 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect26+ github.com/dustin/go-humanize v1.0.1 // indirect27+ github.com/google/uuid v1.6.0 // indirect28+ github.com/klauspost/compress v1.17.9 // indirect2129 github.com/lucasb-eyer/go-colorful v1.4.1 // indirect30+ github.com/mattn/go-isatty v0.0.24 // indirect2231 github.com/mattn/go-runewidth v0.0.24 // indirect2332 github.com/muesli/cancelreader v0.2.2 // indirect33+ github.com/ncruces/go-strftime v1.0.0 // indirect34+ github.com/parquet-go/bitpack v1.0.0 // indirect35+ github.com/parquet-go/jsonlite v1.0.0 // indirect36+ github.com/pierrec/lz4/v4 v4.1.21 // indirect37+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect38+ github.com/richardlehane/mscfb v1.0.7 // indirect39+ github.com/richardlehane/msoleps v1.0.6 // indirect2440 github.com/rivo/uniseg v0.4.7 // indirect41+ github.com/tiendc/go-deepcopy v1.7.2 // indirect42+ github.com/twpayne/go-geom v1.6.1 // indirect2543 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect44+ github.com/xuri/efp v0.0.1 // indirect45+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect46+ golang.org/x/crypto v0.53.0 // indirect47+ golang.org/x/net v0.56.0 // indirect2648 golang.org/x/sync v0.22.0 // indirect2749 golang.org/x/sys v0.47.0 // indirect50+ google.golang.org/protobuf v1.34.2 // indirect51+ modernc.org/libc v1.75.7 // indirect52+ modernc.org/mathutil v1.7.1 // indirect53+ modernc.org/memory v1.12.1 // indirect2854 )
M go.sum +104 -2
@@ -2,10 +2,16 @@ charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
22 charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=33 charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=44 charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=5-github.com/FelineStateMachine/typesafe-go v0.1.0 h1:zJm2QvIGipgC8HiybTqeSj41EVyEjbwKL2aDX2rDPF0=6-github.com/FelineStateMachine/typesafe-go v0.1.0/go.mod h1:+8AvGJI/KbRkfIjRawUc3Y+PjmhnfmttA2m9HQg3sa8=5+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=6+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=77 github.com/FelineStateMachine/typesafe-go v0.2.0 h1:7YFrdcsuvTvBf0dhOvifZVbM8d4StrckWJC3yEH3Ydk=88 github.com/FelineStateMachine/typesafe-go v0.2.0/go.mod h1:+8AvGJI/KbRkfIjRawUc3Y+PjmhnfmttA2m9HQg3sa8=9+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=10+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=11+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=12+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=13+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=14+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=915 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=1016 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=1117 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
@@ -26,23 +32,119 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
2632 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=2733 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=2834 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=35+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=36+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=37+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=38+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=39+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=40+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=41+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=42+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=43+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=44+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=45+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=46+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=47+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=48+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=49+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=50+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=2951 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=3052 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=3153 github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=3254 github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=55+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=56+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=3357 github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=3458 github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=3559 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=3660 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=61+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=62+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=63+github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=64+github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=65+github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=66+github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=67+github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM=68+github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=69+github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=70+github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=71+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=72+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=73+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=74+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=75+github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=76+github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=77+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=78+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=3779 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=3880 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=3981 github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=4082 github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=83+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=84+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=85+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=86+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=87+github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=88+github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=4189 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=4290 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=91+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=92+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=93+github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=94+github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=95+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=96+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=97+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=98+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=99+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=100+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=43101 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=44102 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=103+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=104+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=105+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=106+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=107+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=108+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=45109 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=46110 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=47111 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=48112 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=113+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=114+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=115+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=116+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=117+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=118+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=119+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=120+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=121+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=122+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=123+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=124+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=125+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=126+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=127+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=128+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=129+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=130+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=131+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=132+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=133+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=134+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=135+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=136+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=137+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=138+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=139+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=140+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=141+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=142+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=143+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=144+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=145+modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=146+modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=147+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=148+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=149+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=150+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
A internal/fileio/builder.go +184 -0
@@ -0,0 +1,184 @@
1+package fileio2+3+import (4+ "fmt"5+ "math"6+ "strconv"7+ "strings"8+9+ "012/internal/sheet"10+)11+12+// builder fills a new sheet for an importer. It keeps text as text (an13+// imported "12%" string must not turn into a number, nor "=A1" into a14+// formula), falls back to a formula's cached value when the formula15+// can't be translated, and counts what didn't fit so the notes can say.16+type builder struct {17+ s *sheet.Sheet18+19+ rowsOut, colsOut int // how far past the sheet's edges the data went20+ values int // formulas kept as their values21+ valueExample string22+}23+24+func newBuilder() *builder { return &builder{s: sheet.New()} }25+26+// fits reports whether a is inside the worksheet, recording by how much27+// data overflowed when it isn't.28+func (b *builder) fits(a sheet.Addr) bool {29+ if a.Row >= sheet.MaxRows {30+ b.rowsOut = max(b.rowsOut, a.Row-sheet.MaxRows+1)31+ }32+ if a.Col >= sheet.MaxCols {33+ b.colsOut = max(b.colsOut, a.Col-sheet.MaxCols+1)34+ }35+ return a.Valid()36+}37+38+// flatten replaces line breaks and tabs, which a cell can't show, with39+// spaces.40+func flatten(s string) string {41+ if !strings.ContainsAny(s, "\r\n\t") {42+ return s43+ }44+ s = strings.ReplaceAll(s, "\r\n", " ")45+ return strings.Map(func(r rune) rune {46+ switch r {47+ case '\r', '\n', '\t':48+ return ' '49+ }50+ return r51+ }, s)52+}53+54+// textInput is the entry that shows s as text: s itself, or s behind a55+// ' when typing it would make a number, a boolean or a formula.56+func textInput(s string) string {57+ s = flatten(s)58+ if s == "" {59+ return ""60+ }61+ if strings.HasPrefix(s, "'") || sheet.IsFormulaEntry(s) {62+ return "'" + s63+ }64+ if _, _, ok := sheet.ParseValue(s); ok {65+ return "'" + s66+ }67+ switch strings.ToUpper(strings.TrimSpace(s)) {68+ case "TRUE", "FALSE":69+ return "'" + s70+ }71+ return s72+}73+74+// entryInput is s as if typed into Sheets, for text formats: numbers,75+// dates, currency and percentages become values with formats. Formulas76+// stay text: a data file shouldn't run formulas (or ask JEV questions)77+// just by being opened.78+func entryInput(s string) string {79+ s = flatten(s)80+ if strings.HasPrefix(s, "'") || sheet.IsFormulaEntry(s) {81+ return "'" + s82+ }83+ return s84+}85+86+// numInput writes v so it parses back to the same number.87+func numInput(v float64) string {88+ if a := math.Abs(v); a == 0 || (a >= 1e-6 && a < 1e21) {89+ return strconv.FormatFloat(v, 'f', -1, 64)90+ }91+ return strconv.FormatFloat(v, 'E', -1, 64)92+}93+94+func (b *builder) put(a sheet.Addr, input string, f sheet.Format, st sheet.Style) {95+ if input == "" && f.IsZero() && st.IsZero() || !b.fits(a) {96+ return97+ }98+ if err := b.s.Load(a, input, f, st); err != nil {99+ // Only formulas fail; keep what was written as text.100+ b.s.Load(a, "'"+input, f, st)101+ }102+}103+104+// text stores s as text.105+func (b *builder) text(a sheet.Addr, s string, f sheet.Format, st sheet.Style) {106+ b.put(a, textInput(s), f, st)107+}108+109+// entry stores s as if typed (see entryInput).110+func (b *builder) entry(a sheet.Addr, s string) {111+ b.put(a, entryInput(s), sheet.Format{}, sheet.Style{})112+}113+114+// number stores v with format f. Plain text formats keep the digits.115+func (b *builder) number(a sheet.Addr, v float64, f sheet.Format, st sheet.Style) {116+ if math.IsNaN(v) || math.IsInf(v, 0) {117+ b.put(a, "'#NUM!", f, st)118+ return119+ }120+ b.put(a, numInput(v), f, st)121+}122+123+func (b *builder) boolean(a sheet.Addr, v bool, st sheet.Style) {124+ b.put(a, strings.ToUpper(strconv.FormatBool(v)), sheet.Format{}, st)125+}126+127+// formula stores a formula in 012's syntax (with the leading =), or,128+// when it doesn't parse, the value it had in the file, stored by keep.129+func (b *builder) formula(a sheet.Addr, text string, f sheet.Format, st sheet.Style, keep func()) {130+ if !b.fits(a) {131+ return132+ }133+ if _, err := sheet.Parse(text); err == nil && b.s.Load(a, text, f, st) == nil {134+ return135+ }136+ b.kept(a, text, keep)137+}138+139+// kept stores a formula's value with keep, counting it for the notes.140+func (b *builder) kept(a sheet.Addr, text string, keep func()) {141+ if !b.fits(a) {142+ return143+ }144+ keep()145+ b.values++146+ if b.valueExample == "" {147+ b.valueExample = strings.TrimSpace(a.String() + " " + text)148+ }149+}150+151+// finish recalculates and returns the sheet with notes on what was lost.152+func (b *builder) finish(notes []string) (*sheet.Sheet, []string) {153+ b.s.RecalcAll()154+ if b.values > 0 {155+ notes = append(notes, fmt.Sprintf("%s kept as values, e.g. %s", count(b.values, "formula", "formulas"), b.valueExample))156+ }157+ if b.rowsOut > 0 {158+ notes = append(notes, fmt.Sprintf("only the first %s rows fit; %s left out", thousands(sheet.MaxRows), count(b.rowsOut, "row", "rows")))159+ }160+ if b.colsOut > 0 {161+ notes = append(notes, fmt.Sprintf("only columns A to %s fit; %s left out", sheet.ColName(sheet.MaxCols-1), count(b.colsOut, "column", "columns")))162+ }163+ return b.s, notes164+}165+166+// count writes n with a singular or plural noun: "1 row", "1,200 rows".167+func count(n int, one, many string) string {168+ if n == 1 {169+ return "1 " + one170+ }171+ return thousands(n) + " " + many172+}173+174+// thousands writes n with thousands separators.175+func thousands(n int) string {176+ s := strconv.Itoa(n)177+ if n < 0 {178+ return "-" + thousands(-n)179+ }180+ for i := len(s) - 3; i > 0; i -= 3 {181+ s = s[:i] + "," + s[i:]182+ }183+ return s184+}
A internal/fileio/csv.go +278 -0
@@ -0,0 +1,278 @@
1+package fileio2+3+import (4+ "bufio"5+ "bytes"6+ "context"7+ "encoding/csv"8+ "errors"9+ "fmt"10+ "io"11+ "os"12+ "unicode/utf16"13+ "unicode/utf8"14+15+ "golang.org/x/text/encoding/charmap"16+17+ "012/internal/sheet"18+)19+20+// sniffSize is how much of a file the dialect sniffer looks at.21+const sniffSize = 64 << 1022+23+// delimiters are the separators the sniffer considers, in order of24+// preference when they score the same.25+var delimiters = []rune{',', '\t', ';', '|'}26+27+// Dialect is how a delimited file is written.28+type Dialect struct {29+ Comma rune30+ Encoding string // "UTF-8", "UTF-16" or "Windows-1252"31+}32+33+// sniff picks the delimiter that splits the sample's records into the34+// same number of fields most consistently, preferring more fields.35+// Quoted fields may contain delimiters and line breaks.36+func sniff(sample []byte, truncated bool) rune {37+ best, bestScore := ',', 0.038+ for _, d := range delimiters {39+ counts := fieldCounts(sample, byte(d), truncated)40+ if len(counts) == 0 {41+ continue42+ }43+ // The most common count of delimiters per record, and how many44+ // records have it.45+ freq := map[int]int{}46+ mode := 047+ for _, n := range counts {48+ freq[n]++49+ if freq[n] > freq[mode] || freq[n] == freq[mode] && n > mode {50+ mode = n51+ }52+ }53+ if mode == 0 {54+ continue55+ }56+ score := float64(freq[mode]) / float64(len(counts)) * (1 + float64(min(mode, 20))/100)57+ if score > bestScore {58+ best, bestScore = d, score59+ }60+ }61+ return best62+}63+64+// fieldCounts counts delimiters outside quotes in each of the first65+// records of sample, skipping blank lines and a record cut off at the66+// end of a truncated sample.67+func fieldCounts(sample []byte, d byte, truncated bool) []int {68+ var counts []int69+ n, inQuote, blank := 0, false, true70+ for _, c := range sample {71+ switch {72+ case c == '"':73+ inQuote = !inQuote74+ blank = false75+ case inQuote:76+ case c == d:77+ n++78+ blank = false79+ case c == '\n':80+ if !blank {81+ counts = append(counts, n)82+ }83+ n, blank = 0, true84+ if len(counts) == 50 {85+ return counts86+ }87+ case c != '\r':88+ blank = false89+ }90+ }91+ if !blank && !truncated {92+ counts = append(counts, n)93+ }94+ return counts95+}96+97+// decode returns a reader of f's text as UTF-8 and the encoding found: a98+// UTF-8 or UTF-16 byte order mark decides, and text that isn't valid99+// UTF-8 is read as Windows-1252, what Excel writes on Windows.100+func decode(r *bufio.Reader) (io.Reader, string, error) {101+ head, err := r.Peek(sniffSize)102+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, bufio.ErrBufferFull) {103+ return nil, "", err104+ }105+ switch {106+ case bytes.HasPrefix(head, []byte{0xEF, 0xBB, 0xBF}):107+ r.Discard(3)108+ return r, "UTF-8", nil109+ case bytes.HasPrefix(head, []byte{0xFF, 0xFE}), bytes.HasPrefix(head, []byte{0xFE, 0xFF}):110+ big := head[0] == 0xFE111+ r.Discard(2)112+ all, err := io.ReadAll(r)113+ if err != nil {114+ return nil, "", err115+ }116+ u := make([]uint16, len(all)/2)117+ for i := range u {118+ if big {119+ u[i] = uint16(all[2*i])<<8 | uint16(all[2*i+1])120+ } else {121+ u[i] = uint16(all[2*i+1])<<8 | uint16(all[2*i])122+ }123+ }124+ return bytes.NewReader([]byte(string(utf16.Decode(u)))), "UTF-16", nil125+ }126+ // A sample cut mid-character is still UTF-8.127+ check := head128+ for i := 0; i < utf8.UTFMax && len(check) > 0 && !utf8.Valid(check); i++ {129+ check = check[:len(check)-1]130+ }131+ if len(head) > 0 && !utf8.Valid(check) {132+ return charmap.Windows1252.NewDecoder().Reader(r), "Windows-1252", nil133+ }134+ return r, "UTF-8", nil135+}136+137+// countingReader counts the bytes read through it.138+type countingReader struct {139+ r io.Reader140+ n int64141+}142+143+func (c *countingReader) Read(p []byte) (int, error) {144+ n, err := c.r.Read(p)145+ c.n += int64(n)146+ return n, err147+}148+149+func importDelimited(ctx context.Context, name string, k Kind, prog *Progress) (*Result, error) {150+ f, err := os.Open(name)151+ if err != nil {152+ return nil, err153+ }154+ defer f.Close()155+ var size int64156+ if st, err := f.Stat(); err == nil && st.Mode().IsRegular() {157+ size = st.Size()158+ }159+ counter := &countingReader{r: f}160+ s, rows, notes, err := readDelimited(ctx, counter, k, func(rows int) {161+ prog.setRows(rows)162+ prog.setFrac(counter.n, size)163+ })164+ if err != nil {165+ return nil, err166+ }167+ return &Result{Sheet: s, Rows: rows, Notes: notes}, nil168+}169+170+// readDelimited reads CSV or TSV text, sniffing the delimiter of CSV.171+// Each field is entered as if typed. progress is called every few172+// hundred rows.173+func readDelimited(ctx context.Context, in io.Reader, k Kind, progress func(rows int)) (*sheet.Sheet, int, []string, error) {174+ br := bufio.NewReaderSize(in, sniffSize)175+ text, enc, err := decode(br)176+ if err != nil {177+ return nil, 0, nil, err178+ }179+ tr := bufio.NewReaderSize(text, sniffSize)180+ sample, _ := tr.Peek(sniffSize)181+ comma := '\t'182+ if k == CSV {183+ comma = sniff(sample, len(sample) == sniffSize)184+ }185+ cr := csv.NewReader(tr)186+ cr.Comma = comma187+ cr.LazyQuotes = true188+ cr.FieldsPerRecord = -1189+ cr.ReuseRecord = true190+191+ b := newBuilder()192+ row := 0193+ for ; ; row++ {194+ rec, err := cr.Read()195+ if errors.Is(err, io.EOF) {196+ break197+ }198+ if err != nil {199+ var pe *csv.ParseError200+ if errors.As(err, &pe) {201+ return nil, 0, nil, fmt.Errorf("line %d: %v", pe.Line, pe.Err)202+ }203+ return nil, 0, nil, err204+ }205+ if row%256 == 0 {206+ if err := ctx.Err(); err != nil {207+ return nil, 0, nil, err208+ }209+ progress(row)210+ }211+ for col, field := range rec {212+ if field != "" {213+ b.entry(sheet.Addr{Col: col, Row: row}, field)214+ }215+ }216+ }217+ progress(row)218+ var notes []string219+ if k == CSV && comma != ',' {220+ notes = append(notes, "separated by "+delimiterName(comma))221+ }222+ if enc != "UTF-8" {223+ notes = append(notes, "read as "+enc)224+ }225+ s, notes := b.finish(notes)226+ return s, row, notes, nil227+}228+229+func delimiterName(r rune) string {230+ switch r {231+ case '\t':232+ return "tabs"233+ case ';':234+ return "semicolons"235+ case '|':236+ return "bars"237+ }238+ return "commas"239+}240+241+// exportDelimited writes the snapshot's displayed values, as Sheets'242+// Download as CSV does: formulas become their results, formats show.243+func exportDelimited(name string, k Kind, snap *Snapshot) (*ExportResult, error) {244+ var buf bytes.Buffer245+ w := csv.NewWriter(&buf)246+ if k == TSV {247+ w.Comma = '\t'248+ }249+ rows := snap.rows()250+ if err := w.WriteAll(rows); err != nil {251+ return nil, err252+ }253+ formulas := 0254+ for _, c := range snap.Cells {255+ if c.Formula {256+ formulas++257+ }258+ }259+ res := &ExportResult{Rows: len(rows)}260+ if formulas > 0 {261+ res.Notes = append(res.Notes, count(formulas, "formula", "formulas")+" saved as values")262+ }263+ return res, writeFile(name, buf.Bytes())264+}265+266+// writeFile writes data atomically: to a temporary file first, then267+// renamed over the target.268+func writeFile(name string, data []byte) error {269+ tmp := name + ".tmp"270+ if err := os.WriteFile(tmp, data, 0o644); err != nil {271+ return err272+ }273+ if err := os.Rename(tmp, name); err != nil {274+ os.Remove(tmp)275+ return err276+ }277+ return nil278+}
A internal/fileio/csv_test.go +245 -0
@@ -0,0 +1,245 @@
1+package fileio2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+10+ "012/internal/sheet"11+)12+13+func addr(t testing.TB, s string) sheet.Addr {14+ t.Helper()15+ a, ok := sheet.ParseAddr(s)16+ if !ok {17+ t.Fatalf("bad address %q", s)18+ }19+ return a20+}21+22+// build makes a sheet from typed entries.23+func build(t testing.TB, entries map[string]string) *sheet.Sheet {24+ t.Helper()25+ s := sheet.New()26+ for a, in := range entries {27+ if err := s.Set(addr(t, a), in); err != nil {28+ t.Fatalf("%s %q: %v", a, in, err)29+ }30+ }31+ return s32+}33+34+// shown is what a cell displays, without a width limit.35+func shown(s *sheet.Sheet, a sheet.Addr) string {36+ return sheet.FormatText(s.Value(a), s.DisplayFormat(a))37+}38+39+func input(s *sheet.Sheet, a sheet.Addr) string {40+ if c := s.Cell(a); c != nil {41+ return c.Input42+ }43+ return ""44+}45+46+func TestSniff(t *testing.T) {47+ for _, tc := range []struct {48+ name, text string49+ want rune50+ }{51+ {"commas", "a,b,c\n1,2,3\n", ','},52+ {"semicolons with decimal commas", "name;price\napple;1,5\npear;2,25\n", ';'},53+ {"tabs", "a\tb\n1\t2\n", '\t'},54+ {"bars", "a|b|c\n1|2|3\n", '|'},55+ {"quoted delimiters", "\"a;b\",c\n\"1;2\",3\n", ','},56+ {"quoted line breaks", "a,\"b\nc\"\n1,2\n", ','},57+ {"one column", "a\nb\nc\n", ','},58+ {"empty", "", ','},59+ } {60+ t.Run(tc.name, func(t *testing.T) {61+ if got := sniff([]byte(tc.text), false); got != tc.want {62+ t.Errorf("sniff = %q, want %q", got, tc.want)63+ }64+ })65+ }66+}67+68+func readText(t *testing.T, k Kind, text string) (*sheet.Sheet, []string) {69+ t.Helper()70+ s, _, notes, err := readDelimited(context.Background(), strings.NewReader(text), k, func(int) {})71+ if err != nil {72+ t.Fatal(err)73+ }74+ return s, notes75+}76+77+func TestReadDelimited(t *testing.T) {78+ for _, tc := range []struct {79+ name string80+ kind Kind81+ text string82+ cells map[string]string // cell -> input83+ shown map[string]string // cell -> displayed84+ note string85+ }{86+ {87+ name: "entries become values with formats, as typed",88+ kind: CSV,89+ text: "Item,Due,Amount,Share\nRent,9/26/2026,\"$1,450.00\",12%\n",90+ cells: map[string]string{"A1": "Item", "B2": "9/26/2026", "C2": "$1,450.00"},91+ shown: map[string]string{"B2": "9/26/2026", "C2": "$1,450.00", "D2": "12%"},92+ },93+ {94+ name: "formulas stay text",95+ kind: CSV,96+ text: "=1+2,+A1,'quoted,@SUM(A1)\n",97+ cells: map[string]string{"A1": "'=1+2", "B1": "'+A1", "C1": "''quoted"},98+ shown: map[string]string{"A1": "=1+2", "B1": "+A1", "C1": "'quoted", "D1": "@SUM(A1)"},99+ },100+ {101+ name: "byte order mark",102+ kind: CSV,103+ text: "\xEF\xBB\xBFname,qty\nx,1\n",104+ cells: map[string]string{"A1": "name", "B2": "1"},105+ },106+ {107+ name: "UTF-16 with BOM",108+ kind: TSV,109+ text: "\xFF\xFEa\x00\t\x00\xE9\x00\n\x001\x00",110+ cells: map[string]string{"A1": "a", "B1": "é", "A2": "1"},111+ note: "read as UTF-16",112+ },113+ {114+ name: "Windows-1252",115+ kind: CSV,116+ text: "caf\xE9,\x80 5\n",117+ cells: map[string]string{"A1": "café", "B1": "€ 5"},118+ note: "read as Windows-1252",119+ },120+ {121+ name: "semicolons",122+ kind: CSV,123+ text: "a;b\n1;2\n",124+ cells: map[string]string{"A1": "a", "B2": "2"},125+ note: "separated by semicolons",126+ },127+ {128+ name: "quoted line breaks become spaces",129+ kind: CSV,130+ text: "\"two\nlines\",\"say \"\"hi\"\"\"\n",131+ cells: map[string]string{"A1": "two lines", "B1": `say "hi"`},132+ },133+ {134+ name: "ragged rows and bare quotes",135+ kind: CSV,136+ text: "a,b,c\n1\n2,x\"y\n",137+ cells: map[string]string{"C1": "c", "A2": "1", "B3": `x"y`},138+ },139+ {140+ name: "TSV keeps commas",141+ kind: TSV,142+ text: "1,5\tTRUE\n",143+ cells: map[string]string{"A1": "1,5", "B1": "TRUE"},144+ shown: map[string]string{"B1": "TRUE"},145+ },146+ } {147+ t.Run(tc.name, func(t *testing.T) {148+ s, notes := readText(t, tc.kind, tc.text)149+ for a, want := range tc.cells {150+ if got := input(s, addr(t, a)); got != want {151+ t.Errorf("%s input = %q, want %q", a, got, want)152+ }153+ }154+ for a, want := range tc.shown {155+ if got := shown(s, addr(t, a)); got != want {156+ t.Errorf("%s shows %q, want %q", a, got, want)157+ }158+ }159+ if tc.note != "" && !strings.Contains(strings.Join(notes, "; "), tc.note) {160+ t.Errorf("notes %q, want %q", notes, tc.note)161+ }162+ })163+ }164+}165+166+func TestReadDelimitedTruncates(t *testing.T) {167+ var b strings.Builder168+ for range sheet.MaxRows + 5 {169+ b.WriteString("x," + strings.Repeat("y,", sheet.MaxCols+2) + "\n")170+ }171+ s, notes := readText(t, CSV, b.String())172+ if got := input(s, sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}); got != "y" {173+ t.Errorf("last cell = %q", got)174+ }175+ joined := strings.Join(notes, "; ")176+ for _, want := range []string{"only the first 8,192 rows fit; 5 rows left out", "only columns A to IV fit; 3 columns left out"} {177+ if !strings.Contains(joined, want) {178+ t.Errorf("notes %q, want %q", joined, want)179+ }180+ }181+}182+183+func TestDelimitedRoundTrip(t *testing.T) {184+ src := build(t, map[string]string{185+ "A1": "Item", "B1": "Due", "C1": "Amount", "D1": "Share", "E1": "Note",186+ "A2": "Rent", "B2": "10/1/2026", "C2": "$1,450.00", "D2": "=C2/C4", "E2": "comma, \"quoted\"",187+ "A3": "Food", "B3": "9/28/2026", "C3": "612.4", "D3": "=C3/C4", "E3": "tab\there",188+ "A4": "Total", "C4": "=SUM(C2:C3)", "E4": "ok",189+ })190+ src.SetFormat(sheet.NewRect(addr(t, "D2"), addr(t, "D3")), sheet.Preset(sheet.FmtPercent))191+ for _, k := range []Kind{CSV, TSV} {192+ t.Run(k.String(), func(t *testing.T) {193+ name := filepath.Join(t.TempDir(), "out"+k.Ext())194+ res, err := Export(context.Background(), name, k, Snap(src, sheet.Rect{}, "out"), ExportOptions{})195+ if err != nil {196+ t.Fatal(err)197+ }198+ if res.Rows != 4 || len(res.Notes) != 1 || res.Notes[0] != "3 formulas saved as values" {199+ t.Errorf("result %+v", res)200+ }201+ got, err := Import(context.Background(), name, Options{})202+ if err != nil {203+ t.Fatal(err)204+ }205+ for _, a := range src.Addrs() {206+ if want, g := shown(src, a), shown(got.Sheet, a); g != want {207+ t.Errorf("%s shows %q after the round trip, want %q", a, g, want)208+ }209+ }210+ // Values come back as numbers with their formats, not text.211+ if v := got.Sheet.Value(addr(t, "C4")); v.Kind != sheet.Number || v.Num != 2062.4 {212+ t.Errorf("C4 = %+v", v)213+ }214+ if f := got.Sheet.DisplayFormat(addr(t, "D2")); f.Kind != sheet.FmtPercent {215+ t.Errorf("D2 format = %+v", f)216+ }217+ })218+ }219+}220+221+func TestExportDelimitedText(t *testing.T) {222+ src := build(t, map[string]string{"A1": "a", "C1": "1/0", "B2": "=1/0", "A3": "TRUE"})223+ name := filepath.Join(t.TempDir(), "x.csv")224+ if _, err := Export(context.Background(), name, CSV, Snap(src, sheet.Rect{}, "x"), ExportOptions{}); err != nil {225+ t.Fatal(err)226+ }227+ data, _ := os.ReadFile(name)228+ want := "a,,1/0\n,#DIV/0!,\nTRUE,,\n"229+ if string(data) != want {230+ t.Errorf("wrote %q, want %q", data, want)231+ }232+}233+234+func FuzzReadDelimited(f *testing.F) {235+ for _, s := range []string{"a,b\n1,2\n", "\"a\"\"b\",\n", "\xFF\xFEa\x00", "\xEF\xBB\xBF=1", "x;\"y\n", "\x00\x01,\"", "1|2|3"} {236+ f.Add([]byte(s), true)237+ }238+ f.Fuzz(func(t *testing.T, data []byte, tsv bool) {239+ k := CSV240+ if tsv {241+ k = TSV242+ }243+ readDelimited(context.Background(), strings.NewReader(string(data)), k, func(int) {})244+ })245+}
A internal/fileio/fileio.go +221 -0
@@ -0,0 +1,221 @@
1+// Package fileio moves data between sheets and other file formats: CSV2+// and TSV, Excel workbooks, SQLite databases, Parquet files and Lotus3+// 1-2-3 worksheets. Everything is pure Go. The sheet package knows none4+// of these formats: importers build a sheet through its public API5+// (Load, SetColWidth, RecalcAll) and exporters read a Snapshot of one.6+package fileio7+8+import (9+ "context"10+ "errors"11+ "fmt"12+ "path/filepath"13+ "strings"14+ "sync/atomic"15+16+ "012/internal/sheet"17+)18+19+// Kind is an external file format.20+type Kind int21+22+const (23+ CSV Kind = iota + 124+ TSV25+ XLSX26+ SQLite27+ Parquet28+ WK129+)30+31+var kinds = []struct {32+ kind Kind33+ name string // short name, e.g. in "Export as CSV"34+ label string // what the file is, for the import picker35+ exts []string // recognized extensions, the first one written36+ export bool37+}{38+ {CSV, "CSV", "Comma-separated values", []string{".csv"}, true},39+ {TSV, "TSV", "Tab-separated values", []string{".tsv", ".tab"}, true},40+ {XLSX, "XLSX", "Excel workbook", []string{".xlsx", ".xlsm"}, true},41+ {SQLite, "SQLite", "SQLite database", []string{".sqlite", ".sqlite3", ".db"}, true},42+ {Parquet, "Parquet", "Parquet file", []string{".parquet"}, false},43+ {WK1, "WK1", "Lotus 1-2-3 worksheet", []string{".wk1", ".wks"}, false},44+}45+46+// Kinds lists every format, in menu order.47+func Kinds() []Kind {48+ out := make([]Kind, len(kinds))49+ for i, k := range kinds {50+ out[i] = k.kind51+ }52+ return out53+}54+55+// String is the format's short name, e.g. "XLSX".56+func (k Kind) String() string {57+ if k < 1 || int(k) > len(kinds) {58+ return "unknown"59+ }60+ return kinds[k-1].name61+}62+63+// Label says what a file of this kind is, e.g. "Excel workbook".64+func (k Kind) Label() string {65+ if k < 1 || int(k) > len(kinds) {66+ return ""67+ }68+ return kinds[k-1].label69+}70+71+// Ext is the extension written for this kind, e.g. ".xlsx".72+func (k Kind) Ext() string {73+ if k < 1 || int(k) > len(kinds) {74+ return ""75+ }76+ return kinds[k-1].exts[0]77+}78+79+// CanExport reports whether sheets can be written in this format.80+func (k Kind) CanExport() bool {81+ return k >= 1 && int(k) <= len(kinds) && kinds[k-1].export82+}83+84+// KindOf recognizes a file's format by its extension.85+func KindOf(name string) (Kind, bool) {86+ ext := strings.ToLower(filepath.Ext(name))87+ for _, k := range kinds {88+ for _, e := range k.exts {89+ if e == ext {90+ return k.kind, true91+ }92+ }93+ }94+ return 0, false95+}96+97+// Progress reports how far an import has got. The importer updates it98+// from its goroutine; the UI reads it on a timer.99+type Progress struct {100+ rows atomic.Int64101+ permille atomic.Int64 // 0-1000, or -1 while the total isn't known102+}103+104+// NewProgress returns a progress with an unknown total.105+func NewProgress() *Progress {106+ p := &Progress{}107+ p.permille.Store(-1)108+ return p109+}110+111+// Get returns the rows read so far and the fraction done, which is112+// negative while unknown.113+func (p *Progress) Get() (rows int, frac float64) {114+ if p == nil {115+ return 0, -1116+ }117+ pm := p.permille.Load()118+ if pm < 0 {119+ return int(p.rows.Load()), -1120+ }121+ return int(p.rows.Load()), float64(pm) / 1000122+}123+124+func (p *Progress) setRows(n int) {125+ if p != nil {126+ p.rows.Store(int64(n))127+ }128+}129+130+// setFrac records done out of total; a non-positive total is unknown.131+func (p *Progress) setFrac(done, total int64) {132+ if p == nil || total <= 0 {133+ return134+ }135+ p.permille.Store(min(done*1000/total, 1000))136+}137+138+// Options tune an import.139+type Options struct {140+ // Table is the SQLite table to import, or Query a SELECT to run.141+ // With neither, a database with one table imports it.142+ Table, Query string143+ Progress *Progress144+}145+146+// Result is an imported sheet and what the import had to leave out or147+// change, in sentences for the context line.148+type Result struct {149+ Sheet *sheet.Sheet150+ Kind Kind151+ Rows int // rows of the file that were read, including a header152+ Notes []string153+}154+155+// ErrNeedTable is returned for a SQLite database with several tables156+// when Options names neither a table nor a query; Tables lists them.157+type ErrNeedTable struct{ Tables []TableInfo }158+159+func (e *ErrNeedTable) Error() string {160+ return fmt.Sprintf("the database has %d tables; pick one", len(e.Tables))161+}162+163+// ErrUnsupported is returned for a file whose extension isn't known.164+var ErrUnsupported = errors.New("not a format 012 can import")165+166+// Import reads the file name into a new sheet. It checks ctx between167+// rows, so a long import can be cancelled.168+func Import(ctx context.Context, name string, opt Options) (*Result, error) {169+ k, ok := KindOf(name)170+ if !ok {171+ return nil, ErrUnsupported172+ }173+ var (174+ r *Result175+ err error176+ )177+ switch k {178+ case CSV, TSV:179+ r, err = importDelimited(ctx, name, k, opt.Progress)180+ case XLSX:181+ r, err = importXLSX(ctx, name, opt.Progress)182+ case SQLite:183+ r, err = importSQLite(ctx, name, opt)184+ case Parquet:185+ r, err = importParquet(ctx, name, opt.Progress)186+ case WK1:187+ r, err = importWK1(ctx, name, opt.Progress)188+ }189+ if err != nil {190+ return nil, err191+ }192+ r.Kind = k193+ if p := opt.Progress; p != nil && p.permille.Load() >= 0 {194+ p.permille.Store(1000)195+ }196+ return r, nil197+}198+199+// ExportOptions tune an export.200+type ExportOptions struct {201+ Table string // SQLite: the table to write; replaced if it exists202+}203+204+// ExportResult says what an export wrote and what it couldn't keep.205+type ExportResult struct {206+ Rows int207+ Notes []string208+}209+210+// Export writes a snapshot of a sheet to name in format k.211+func Export(ctx context.Context, name string, k Kind, snap *Snapshot, opt ExportOptions) (*ExportResult, error) {212+ switch k {213+ case CSV, TSV:214+ return exportDelimited(name, k, snap)215+ case XLSX:216+ return exportXLSX(name, snap)217+ case SQLite:218+ return exportSQLite(ctx, name, snap, opt.Table)219+ }220+ return nil, fmt.Errorf("can't export %s files", k)221+}
A internal/fileio/formula.go +129 -0
@@ -0,0 +1,129 @@
1+package fileio2+3+import (4+ "strings"5+)6+7+// 012 reads Sheets' formula syntax and some of 1-2-3's (@SUM, A1..B3,8+// #AND#). Excel files store formulas without the leading =, and prefix9+// functions newer than Excel 2007 with _xlfn.10+11+// xlfn lists 012's functions that Excel stores with the _xlfn. prefix.12+var xlfn = map[string]bool{13+ "CONCAT": true, "DAYS": true, "IFNA": true, "IFS": true, "SWITCH": true,14+ "TEXTJOIN": true, "XLOOKUP": true, "XOR": true,15+}16+17+// toExcelFormula translates a formula entry to Excel's syntax, or18+// reports false when Excel has no equivalent (JEV functions, 1-2-3's19+// #AND# operators), in which case the value is exported instead.20+func toExcelFormula(input string) (string, bool) {21+ src := strings.TrimPrefix(input, "=")22+ var b strings.Builder23+ for i := 0; i < len(src); i++ {24+ c := src[i]25+ switch {26+ case c == '"':27+ j := i + 128+ for j < len(src) {29+ if src[j] == '"' {30+ if j+1 < len(src) && src[j+1] == '"' {31+ j += 232+ continue33+ }34+ break35+ }36+ j++37+ }38+ if j >= len(src) {39+ return "", false40+ }41+ b.WriteString(src[i : j+1])42+ i = j43+ case c == '#':44+ if strings.HasPrefix(strings.ToUpper(src[i:]), "#REF!") {45+ b.WriteString("#REF!")46+ i += len("#REF!") - 147+ continue48+ }49+ return "", false // #AND#, #OR#, #NOT#50+ case c == '.' && i+1 < len(src) && src[i+1] == '.':51+ b.WriteByte(':')52+ i++53+ case c == '@' || isIdentByte(c) && !isDigitByte(c) && c != '.':54+ j := i55+ if c == '@' {56+ j++57+ }58+ start := j59+ for j < len(src) && isIdentByte(src[j]) && !strings.HasPrefix(src[j:], "..") {60+ j++61+ }62+ name := src[start:j]63+ k := j64+ for k < len(src) && src[k] == ' ' {65+ k++66+ }67+ if k < len(src) && src[k] == '(' || c == '@' {68+ name = strings.ToUpper(name)69+ if strings.HasPrefix(name, "JEV.") {70+ return "", false71+ }72+ if xlfn[name] {73+ name = "_xlfn." + name74+ }75+ if c == '@' && (k >= len(src) || src[k] != '(') {76+ name += "()" // @PI77+ }78+ }79+ b.WriteString(name)80+ i = j - 181+ default:82+ b.WriteByte(c)83+ }84+ }85+ return b.String(), true86+}87+88+// fromExcelFormula translates an Excel formula (without its =) to a 01289+// entry. The result may still fail to parse, e.g. with references to90+// other sheets; the importer then keeps the cell's value.91+func fromExcelFormula(f string) string {92+ f = strings.TrimPrefix(f, "=")93+ var b strings.Builder94+ b.WriteByte('=')95+ inStr := false96+ for i := 0; i < len(f); i++ {97+ c := f[i]98+ if c == '"' {99+ inStr = !inStr100+ }101+ if !inStr {102+ rest := f[i:]103+ if p := prefixFold(rest, "_xlfn."); p > 0 {104+ i += p - 1105+ continue106+ }107+ if p := prefixFold(rest, "_xlws."); p > 0 {108+ i += p - 1109+ continue110+ }111+ }112+ b.WriteByte(c)113+ }114+ return b.String()115+}116+117+// prefixFold returns len(prefix) if s starts with it, ignoring case.118+func prefixFold(s, prefix string) int {119+ if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {120+ return len(prefix)121+ }122+ return 0123+}124+125+func isIdentByte(c byte) bool {126+ return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c == '$' || c == '.' || isDigitByte(c)127+}128+129+func isDigitByte(c byte) bool { return c >= '0' && c <= '9' }
A internal/fileio/numfmt.go +210 -0
@@ -0,0 +1,210 @@
1+package fileio2+3+import (4+ "strings"5+ "sync"6+7+ "012/internal/sheet"8+)9+10+// Number formats in files are Excel format codes. 012's formats map to11+// the codes Sheets writes for them, and codes map back to the format12+// that writes them, so formats survive a round trip; any other code is13+// kept as a custom pattern, which 012 renders as Sheets would.14+15+// excelCode is the Excel format code for f, or "" for General.16+func excelCode(f sheet.Format) string {17+ dec := ""18+ if f.Decimals > 0 {19+ dec = "." + strings.Repeat("0", min(f.Decimals, sheet.MaxDecimals))20+ }21+ switch f.Kind {22+ case sheet.FmtText:23+ return "@"24+ case sheet.FmtNumber:25+ return "#,##0" + dec26+ case sheet.FmtPercent:27+ return "0" + dec + "%"28+ case sheet.FmtScientific:29+ return "0" + dec + "E+00"30+ case sheet.FmtAccounting:31+ return `_("$"* #,##0` + dec + `_);_("$"* \(#,##0` + dec + `\);_("$"* "-"??_);_(@_)`32+ case sheet.FmtFinancial:33+ return "#,##0" + dec + ";(#,##0" + dec + ")"34+ case sheet.FmtCurrency:35+ return `"$"#,##0` + dec36+ case sheet.FmtDate:37+ return orDefault(f.Pattern, "m/d/yyyy")38+ case sheet.FmtTime:39+ return orDefault(f.Pattern, "h:mm:ss AM/PM")40+ case sheet.FmtDateTime:41+ return orDefault(f.Pattern, "m/d/yyyy h:mm:ss")42+ case sheet.FmtDuration:43+ return orDefault(f.Pattern, "[h]:mm:ss")44+ case sheet.FmtCustom:45+ return f.Pattern46+ }47+ return ""48+}49+50+func orDefault(s, def string) string {51+ if s == "" {52+ return def53+ }54+ return s55+}56+57+// builtinCodes are Excel's built-in number formats by ID, for files that58+// refer to them without spelling out the code.59+var builtinCodes = map[int]string{60+ 1: "0", 2: "0.00", 3: "#,##0", 4: "#,##0.00",61+ 5: `"$"#,##0_);\("$"#,##0\)`, 6: `"$"#,##0_);[Red]\("$"#,##0\)`,62+ 7: `"$"#,##0.00_);\("$"#,##0.00\)`, 8: `"$"#,##0.00_);[Red]\("$"#,##0.00\)`,63+ 9: "0%", 10: "0.00%", 11: "0.00E+00", 12: "# ?/?", 13: "# ??/??",64+ 14: "m/d/yyyy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy",65+ 18: "h:mm AM/PM", 19: "h:mm:ss AM/PM", 20: "h:mm", 21: "h:mm:ss", 22: "m/d/yyyy h:mm",66+ 37: "#,##0 ;(#,##0)", 38: "#,##0 ;[Red](#,##0)", 39: "#,##0.00;(#,##0.00)", 40: "#,##0.00;[Red](#,##0.00)",67+ 41: `_(* #,##0_);_(* \(#,##0\);_(* "-"_);_(@_)`, 42: `_("$"* #,##0_);_("$"* \(#,##0\);_("$"* "-"_);_(@_)`,68+ 43: `_(* #,##0.00_);_(* \(#,##0.00\);_(* "-"??_);_(@_)`, 44: `_("$"* #,##0.00_);_("$"* \(#,##0.00\);_("$"* "-"??_);_(@_)`,69+ 45: "mm:ss", 46: "[h]:mm:ss", 47: "mm:ss.0", 48: "##0.0E+0", 49: "@",70+}71+72+// builtinFormats are built-in IDs whose meaning is one of 012's formats73+// although the code differs from the one 012 writes.74+var builtinFormats = map[int]sheet.Format{75+ 5: {Kind: sheet.FmtCurrency}, 6: {Kind: sheet.FmtCurrency},76+ 7: {Kind: sheet.FmtCurrency, Decimals: 2}, 8: {Kind: sheet.FmtCurrency, Decimals: 2},77+ 37: {Kind: sheet.FmtFinancial}, 38: {Kind: sheet.FmtFinancial},78+ 39: {Kind: sheet.FmtFinancial, Decimals: 2}, 40: {Kind: sheet.FmtFinancial, Decimals: 2},79+ 42: {Kind: sheet.FmtAccounting},80+}81+82+var (83+ knownOnce sync.Once84+ knownCodes map[string]sheet.Format // normalized code -> format85+)86+87+// normCode folds the spellings of a code that mean the same thing: case,88+// a locale tag, and \$ for "$".89+func normCode(code string) string {90+ code = strings.ReplaceAll(code, "[$-409]", "")91+ code = strings.ReplaceAll(code, "[$$-409]", `"$"`)92+ code = strings.ReplaceAll(code, `\$`, `"$"`)93+ return strings.ToLower(strings.TrimSpace(code))94+}95+96+// formatOf maps an Excel number format, by built-in ID or code, to a 01297+// format.98+func formatOf(id int, code string) sheet.Format {99+ if f, ok := builtinFormats[id]; ok && code == "" {100+ return f101+ }102+ if code == "" {103+ code = builtinCodes[id]104+ }105+ switch normCode(code) {106+ case "", "general":107+ return sheet.Format{}108+ }109+ knownOnce.Do(func() {110+ knownCodes = map[string]sheet.Format{}111+ for _, k := range []sheet.FormatKind{sheet.FmtAccounting, sheet.FmtFinancial, sheet.FmtScientific,112+ sheet.FmtPercent, sheet.FmtCurrency, sheet.FmtNumber} {113+ for d := sheet.MaxDecimals; d >= 0; d-- {114+ f := sheet.Format{Kind: k, Decimals: d}115+ knownCodes[normCode(excelCode(f))] = f116+ }117+ }118+ for _, k := range []sheet.FormatKind{sheet.FmtText, sheet.FmtDate, sheet.FmtTime, sheet.FmtDateTime, sheet.FmtDuration} {119+ knownCodes[normCode(excelCode(sheet.Format{Kind: k}))] = sheet.Format{Kind: k}120+ }121+ knownCodes["m/d/yyyy h:mm"] = sheet.Format{Kind: sheet.FmtDateTime, Pattern: "m/d/yyyy h:mm"}122+ })123+ if f, ok := knownCodes[normCode(code)]; ok {124+ return f125+ }126+ if f, ok := builtinFormats[id]; ok {127+ return f128+ }129+ switch kind := dateKind(code); kind {130+ case sheet.FmtDate, sheet.FmtTime, sheet.FmtDateTime, sheet.FmtDuration:131+ return sheet.Format{Kind: kind, Pattern: code}132+ }133+ return sheet.Format{Kind: sheet.FmtCustom, Pattern: code}134+}135+136+// dateKind says whether a code shows a date, a time, both, or a137+// duration, looking at its letters outside quotes and brackets; it138+// returns FmtCustom for anything else.139+func dateKind(code string) sheet.FormatKind {140+ date, clock, elapsed := false, false, false141+ inQuote, inBracket := false, false142+ for i := 0; i < len(code); i++ {143+ c := code[i]144+ switch {145+ case c == '"':146+ inQuote = !inQuote147+ case inQuote:148+ case c == '\\':149+ i++150+ case c == '[':151+ inBracket = true152+ if i+1 < len(code) && strings.ContainsRune("hHmMsS", rune(code[i+1])) {153+ elapsed = true154+ }155+ case c == ']':156+ inBracket = false157+ case inBracket:158+ case c == 'd' || c == 'D' || c == 'y' || c == 'Y':159+ date = true160+ case c == 'h' || c == 'H' || c == 's' || c == 'S':161+ clock = true162+ case c == '0' || c == '#' || c == '?':163+ if !clock && !date {164+ return sheet.FmtCustom165+ }166+ }167+ }168+ switch {169+ case elapsed:170+ return sheet.FmtDuration171+ case date && clock:172+ return sheet.FmtDateTime173+ case date:174+ return sheet.FmtDate175+ case clock:176+ return sheet.FmtTime177+ }178+ return sheet.FmtCustom179+}180+181+// isDateFormat reports whether f shows a calendar date, whose serial182+// numbers differ between Excel and 012 before March 1900.183+func isDateFormat(f sheet.Format) bool {184+ switch f.Kind {185+ case sheet.FmtDate, sheet.FmtDateTime:186+ return true187+ case sheet.FmtCustom:188+ k := dateKind(f.Pattern)189+ return k == sheet.FmtDate || k == sheet.FmtDateTime190+ }191+ return false192+}193+194+// Excel and 1-2-3 count 1900 as a leap year, so their serials before195+// March 1, 1900 are one less than 012's (and Sheets').196+const leapBug = 61197+198+func fromExcelSerial(v float64, f sheet.Format) float64 {199+ if isDateFormat(f) && v >= 1 && v < leapBug-1 {200+ return v + 1201+ }202+ return v203+}204+205+func toExcelSerial(v float64, f sheet.Format) float64 {206+ if isDateFormat(f) && v >= 1 && v < leapBug {207+ return v - 1208+ }209+ return v210+}
A internal/fileio/parquet.go +231 -0
@@ -0,0 +1,231 @@
1+package fileio2+3+import (4+ "context"5+ "errors"6+ "io"7+ "math"8+ "math/big"9+ "os"10+ "strings"11+ "time"12+13+ "github.com/parquet-go/parquet-go"14+ "github.com/parquet-go/parquet-go/format"15+16+ "012/internal/sheet"17+)18+19+// parquetColumn is how a leaf column's values become cells.20+type parquetColumn struct {21+ logical format.LogicalTypeValue22+ kind parquet.Kind23+}24+25+// importParquet reads every leaf column, named by its path, into a26+// sheet with a header row. Repeated values in a row are joined with27+// commas.28+func importParquet(ctx context.Context, name string, prog *Progress) (*Result, error) {29+ f, err := os.Open(name)30+ if err != nil {31+ return nil, err32+ }33+ defer f.Close()34+ st, err := f.Stat()35+ if err != nil {36+ return nil, err37+ }38+ pf, err := parquet.OpenFile(f, st.Size())39+ if err != nil {40+ return nil, err41+ }42+ schema := pf.Schema()43+ paths := schema.Columns()44+ cols := make([]parquetColumn, len(paths))45+ b := newBuilder()46+ header := sheet.Style{Bold: true}47+ for i, path := range paths {48+ b.text(sheet.Addr{Col: i}, strings.Join(path, "."), sheet.Format{}, header)49+ if leaf, ok := schema.Lookup(path...); ok && leaf.Node != nil {50+ t := leaf.Node.Type()51+ cols[i].kind = t.Kind()52+ if lt := t.LogicalType(); lt != nil {53+ cols[i].logical = lt.Value54+ }55+ }56+ }57+58+ total := pf.NumRows()59+ row := 160+ buf := make([]parquet.Row, 256)61+ lists := 062+ for _, rg := range pf.RowGroups() {63+ rows := rg.Rows()64+ for {65+ if err := ctx.Err(); err != nil {66+ rows.Close()67+ return nil, err68+ }69+ n, err := rows.ReadRows(buf)70+ for _, r := range buf[:n] {71+ if putParquetRow(b, row, r, cols) {72+ lists++73+ }74+ row++75+ }76+ prog.setRows(row)77+ prog.setFrac(int64(row-1), total)78+ if errors.Is(err, io.EOF) {79+ break80+ }81+ if err != nil {82+ rows.Close()83+ return nil, err84+ }85+ if n == 0 {86+ break87+ }88+ }89+ rows.Close()90+ }91+ var notes []string92+ if lists > 0 {93+ notes = append(notes, "repeated values joined with commas")94+ }95+ s, notes := b.finish(notes)96+ return &Result{Sheet: s, Rows: row, Notes: notes}, nil97+}98+99+// putParquetRow stores one row, reporting whether a column repeated.100+func putParquetRow(b *builder, row int, r parquet.Row, cols []parquetColumn) (repeated bool) {101+ if row >= sheet.MaxRows {102+ b.fits(sheet.Addr{Row: row})103+ return false104+ }105+ // Values arrive grouped by column; a repeated column has several.106+ for i := 0; i < len(r); {107+ col := r[i].Column()108+ j := i + 1109+ for j < len(r) && r[j].Column() == col {110+ j++111+ }112+ if col < 0 || col >= len(cols) {113+ i = j114+ continue115+ }116+ a := sheet.Addr{Col: col, Row: row}117+ vals := r[i:j]118+ if len(vals) > 1 {119+ parts := make([]string, 0, len(vals))120+ for _, v := range vals {121+ if !v.IsNull() {122+ parts = append(parts, parquetText(v, cols[col]))123+ }124+ }125+ if len(parts) > 0 {126+ b.text(a, strings.Join(parts, ", "), sheet.Format{}, sheet.Style{})127+ repeated = true128+ }129+ } else if !vals[0].IsNull() {130+ putParquetValue(b, a, vals[0], cols[col])131+ }132+ i = j133+ }134+ return repeated135+}136+137+func putParquetValue(b *builder, a sheet.Addr, v parquet.Value, c parquetColumn) {138+ switch lt := c.logical.(type) {139+ case *format.DateType:140+ b.number(a, float64(v.Int32())+serialOf(time.Unix(0, 0).UTC()), sheet.Format{Kind: sheet.FmtDate}, sheet.Style{})141+ return142+ case *format.TimestampType:143+ t := timestamp(v.Int64(), lt.Unit)144+ b.number(a, serialOf(t), sheet.Format{Kind: sheet.FmtDateTime}, sheet.Style{})145+ return146+ case *format.TimeType:147+ d := timeUnit(lt.Unit) * time.Duration(timeInt(v))148+ b.number(a, d.Hours()/24, sheet.Format{Kind: sheet.FmtTime}, sheet.Style{})149+ return150+ case *format.DecimalType:151+ if x, ok := decimal(v, lt.Scale); ok {152+ b.number(a, x, sheet.Format{}, sheet.Style{})153+ return154+ }155+ }156+ switch c.kind {157+ case parquet.Boolean:158+ b.boolean(a, v.Boolean(), sheet.Style{})159+ case parquet.Int32:160+ b.number(a, float64(v.Int32()), sheet.Format{}, sheet.Style{})161+ case parquet.Int64:162+ b.number(a, float64(v.Int64()), sheet.Format{}, sheet.Style{})163+ case parquet.Float:164+ b.number(a, float64(v.Float()), sheet.Format{}, sheet.Style{})165+ case parquet.Double:166+ b.number(a, v.Double(), sheet.Format{}, sheet.Style{})167+ case parquet.Int96:168+ // Legacy timestamps: nanoseconds of the day, then the Julian day.169+ i := v.Int96()170+ nanos := int64(i[1])<<32 | int64(i[0])171+ days := int64(i[2]) - 2440588 // the Julian day of 1970-01-01172+ t := time.Unix(days*86400, nanos).UTC()173+ b.number(a, serialOf(t), sheet.Format{Kind: sheet.FmtDateTime}, sheet.Style{})174+ default:175+ textOrDate(b, a, parquetText(v, c))176+ }177+}178+179+// parquetText is a value as text, for byte arrays and joined lists.180+func parquetText(v parquet.Value, c parquetColumn) string {181+ switch c.kind {182+ case parquet.ByteArray, parquet.FixedLenByteArray:183+ if _, isDec := c.logical.(*format.DecimalType); !isDec {184+ return string(v.ByteArray())185+ }186+ }187+ return v.String()188+}189+190+func timeUnit(u format.TimeUnit) time.Duration {191+ if u.Value == nil {192+ return time.Millisecond193+ }194+ return u.Value.Duration()195+}196+197+func timestamp(n int64, u format.TimeUnit) time.Time {198+ d := timeUnit(u)199+ return time.Unix(0, 0).UTC().Add(time.Duration(n) * d)200+}201+202+func timeInt(v parquet.Value) int64 {203+ if v.Kind() == parquet.Int32 {204+ return int64(v.Int32())205+ }206+ return v.Int64()207+}208+209+// decimal reads a DECIMAL stored as an integer or as big-endian bytes.210+func decimal(v parquet.Value, scale int32) (float64, bool) {211+ var unscaled float64212+ switch v.Kind() {213+ case parquet.Int32:214+ unscaled = float64(v.Int32())215+ case parquet.Int64:216+ unscaled = float64(v.Int64())217+ case parquet.ByteArray, parquet.FixedLenByteArray:218+ raw := v.ByteArray()219+ if len(raw) == 0 {220+ return 0, false221+ }222+ n := new(big.Int).SetBytes(raw)223+ if raw[0]&0x80 != 0 { // two's complement224+ n.Sub(n, new(big.Int).Lsh(big.NewInt(1), uint(8*len(raw))))225+ }226+ unscaled, _ = new(big.Float).SetInt(n).Float64()227+ default:228+ return 0, false229+ }230+ return unscaled / math.Pow10(int(scale)), true231+}
A internal/fileio/parquet_test.go +75 -0
@@ -0,0 +1,75 @@
1+package fileio2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "testing"8+ "time"9+10+ "github.com/parquet-go/parquet-go"11+)12+13+type trip struct {14+ City string `parquet:"city"`15+ Miles float64 `parquet:"miles"`16+ Riders int32 `parquet:"riders"`17+ Paid bool `parquet:"paid"`18+ Day int32 `parquet:"day,date"` // days since 197019+ Started time.Time `parquet:"started,timestamp(millisecond)"`20+ Note *string `parquet:"note,optional"`21+ Tags []string `parquet:"tags,list"`22+}23+24+func TestParquetImport(t *testing.T) {25+ note := "late"26+ day := time.Date(2026, 9, 26, 0, 0, 0, 0, time.UTC)27+ rows := []trip{28+ {City: "Boston", Miles: 12.5, Riders: 2, Paid: true, Day: int32(day.Unix() / 86400), Started: day.Add(14*time.Hour + 30*time.Minute), Note: &note, Tags: []string{"a", "b"}},29+ {City: "=evil()", Miles: 3, Riders: 1, Day: int32(day.Unix()/86400) + 1, Started: day},30+ }31+ name := filepath.Join(t.TempDir(), "trips.parquet")32+ f, err := os.Create(name)33+ if err != nil {34+ t.Fatal(err)35+ }36+ w := parquet.NewGenericWriter[trip](f)37+ if _, err := w.Write(rows); err != nil {38+ t.Fatal(err)39+ }40+ if err := w.Close(); err != nil {41+ t.Fatal(err)42+ }43+ f.Close()44+45+ prog := NewProgress()46+ res, err := Import(context.Background(), name, Options{Progress: prog})47+ if err != nil {48+ t.Fatal(err)49+ }50+ s := res.Sheet51+ for cell, want := range map[string]string{52+ "A1": "city", "B1": "miles", "E1": "day", "G1": "note", "H1": "tags.list.element",53+ "A2": "Boston", "B2": "12.5", "C2": "2", "D2": "TRUE", "E2": "9/26/2026", "F2": "9/26/2026 14:30:00",54+ "G2": "late", "H2": "a, b",55+ "A3": "=evil()", "D3": "FALSE", "E3": "9/27/2026", "G3": "", "H3": "",56+ } {57+ if g := shown(s, addr(t, cell)); g != want {58+ t.Errorf("%s shows %q, want %q", cell, g, want)59+ }60+ }61+ if rows, frac := prog.Get(); rows != 3 || frac != 1 {62+ t.Errorf("progress %d, %v", rows, frac)63+ }64+ if len(res.Notes) != 1 || res.Notes[0] != "repeated values joined with commas" {65+ t.Errorf("notes %q", res.Notes)66+ }67+}68+69+func TestParquetNotParquet(t *testing.T) {70+ name := filepath.Join(t.TempDir(), "x.parquet")71+ os.WriteFile(name, []byte("not parquet"), 0o644)72+ if _, err := Import(context.Background(), name, Options{}); err == nil {73+ t.Errorf("imported junk")74+ }75+}
A internal/fileio/snapshot.go +73 -0
@@ -0,0 +1,73 @@
1+package fileio2+3+import (4+ "012/internal/sheet"5+)6+7+// Snapshot is a copy of the part of a sheet being exported, taken on the8+// UI goroutine so the file can be written in the background while the9+// sheet keeps changing.10+type Snapshot struct {11+ // Range is what's exported. Whole-sheet exports start at A1, as12+ // Sheets' downloads do, so cells keep their addresses.13+ Range sheet.Rect14+ Cells map[sheet.Addr]SnapCell15+ Widths map[int]int // non-default column widths16+ Name string // what to call the data: a sheet or table name17+}18+19+// SnapCell is one non-blank cell of a snapshot.20+type SnapCell struct {21+ Input string22+ Value sheet.Value23+ Format sheet.Format // as displayed, including one inferred from a formula24+ Own sheet.Format // the cell's own format25+ Style sheet.Style26+ Formula bool27+}28+29+// Text is the cell as displayed, without a width limit.30+func (c SnapCell) Text() string {31+ return sheet.FormatText(c.Value, c.Format)32+}33+34+// Snap copies the cells of s in r. A zero r means the whole sheet, from35+// A1 to the last cell with contents.36+func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {37+ if r == (sheet.Rect{}) {38+ r, _ = s.UsedRange()39+ }40+ snap := &Snapshot{Range: r, Cells: map[sheet.Addr]SnapCell{}, Widths: s.Widths(), Name: name}41+ for _, a := range s.Addrs() {42+ if !r.Contains(a) {43+ continue44+ }45+ c := s.Cell(a)46+ snap.Cells[a] = SnapCell{47+ Input: c.Input,48+ Value: c.Value,49+ Format: s.DisplayFormat(a),50+ Own: c.Format,51+ Style: c.Style,52+ Formula: c.IsFormula(),53+ }54+ }55+ return snap56+}57+58+// rows returns the snapshot's displayed text row by row across its59+// range, for text formats.60+func (s *Snapshot) rows() [][]string {61+ r := s.Range62+ out := make([][]string, 0, r.To.Row-r.From.Row+1)63+ for row := r.From.Row; row <= r.To.Row; row++ {64+ line := make([]string, r.To.Col-r.From.Col+1)65+ for col := r.From.Col; col <= r.To.Col; col++ {66+ if c, ok := s.Cells[sheet.Addr{Col: col, Row: row}]; ok {67+ line[col-r.From.Col] = c.Text()68+ }69+ }70+ out = append(out, line)71+ }72+ return out73+}
A internal/fileio/sqlite.go +411 -0
@@ -0,0 +1,411 @@
1+package fileio2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "fmt"8+ "math"9+ "os"10+ "strings"11+ "time"12+ "unicode/utf8"13+14+ _ "modernc.org/sqlite" // registers the "sqlite" driver15+16+ "012/internal/sheet"17+)18+19+// TableInfo describes a table or view in a SQLite database, for the20+// table picker.21+type TableInfo struct {22+ Name string23+ View bool24+ Rows int25+ Cols []string26+}27+28+// openSQLite opens an existing database for reading only.29+func openSQLite(ctx context.Context, name string) (*sql.DB, error) {30+ if _, err := os.Stat(name); err != nil {31+ return nil, err32+ }33+ db, err := sql.Open("sqlite", name)34+ if err != nil {35+ return nil, err36+ }37+ db.SetMaxOpenConns(1)38+ if _, err := db.ExecContext(ctx, "PRAGMA query_only = 1"); err != nil {39+ db.Close()40+ return nil, sqliteErr(err)41+ }42+ return db, nil43+}44+45+// sqliteErr shortens the driver's errors, e.g. "file is not a database46+// (26)" for a file that isn't SQLite.47+func sqliteErr(err error) error {48+ msg := err.Error()49+ if i := strings.Index(msg, ": "); i >= 0 && strings.HasPrefix(msg, "SQL logic error") {50+ msg = msg[i+2:]51+ }52+ msg = strings.TrimSuffix(msg, " (1)")53+ return errors.New(msg)54+}55+56+// quoteIdent quotes a table or column name for SQL.57+func quoteIdent(s string) string {58+ return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`59+}60+61+// Tables lists the tables and views of a SQLite database with their row62+// counts and columns.63+func Tables(ctx context.Context, name string) ([]TableInfo, error) {64+ db, err := openSQLite(ctx, name)65+ if err != nil {66+ return nil, err67+ }68+ defer db.Close()69+ return tables(ctx, db)70+}71+72+func tables(ctx context.Context, db *sql.DB) ([]TableInfo, error) {73+ rows, err := db.QueryContext(ctx, `SELECT name, type FROM sqlite_master74+ WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`)75+ if err != nil {76+ return nil, sqliteErr(err)77+ }78+ var out []TableInfo79+ for rows.Next() {80+ var t TableInfo81+ var typ string82+ if err := rows.Scan(&t.Name, &typ); err != nil {83+ rows.Close()84+ return nil, err85+ }86+ t.View = typ == "view"87+ out = append(out, t)88+ }89+ rows.Close()90+ if err := rows.Err(); err != nil {91+ return nil, sqliteErr(err)92+ }93+ for i := range out {94+ t := &out[i]95+ db.QueryRowContext(ctx, "SELECT count(*) FROM "+quoteIdent(t.Name)).Scan(&t.Rows)96+ if r, err := db.QueryContext(ctx, "SELECT * FROM "+quoteIdent(t.Name)+" LIMIT 0"); err == nil {97+ t.Cols, _ = r.Columns()98+ r.Close()99+ }100+ }101+ return out, nil102+}103+104+func importSQLite(ctx context.Context, name string, opt Options) (*Result, error) {105+ db, err := openSQLite(ctx, name)106+ if err != nil {107+ return nil, err108+ }109+ defer db.Close()110+ query, total := opt.Query, 0111+ if query == "" {112+ table := opt.Table113+ if table == "" {114+ ts, err := tables(ctx, db)115+ switch {116+ case err != nil:117+ return nil, err118+ case len(ts) == 0:119+ return nil, errors.New("the database has no tables")120+ case len(ts) > 1:121+ return nil, &ErrNeedTable{Tables: ts}122+ }123+ table = ts[0].Name124+ }125+ query = "SELECT * FROM " + quoteIdent(table)126+ if err := db.QueryRowContext(ctx, "SELECT count(*) FROM "+quoteIdent(table)).Scan(&total); err != nil {127+ return nil, sqliteErr(err)128+ }129+ }130+ rows, err := db.QueryContext(ctx, query)131+ if err != nil {132+ return nil, sqliteErr(err)133+ }134+ defer rows.Close()135+ cols, err := rows.Columns()136+ if err != nil {137+ return nil, sqliteErr(err)138+ }139+140+ b := newBuilder()141+ header := sheet.Style{Bold: true}142+ for c, name := range cols {143+ b.text(sheet.Addr{Col: c}, name, sheet.Format{}, header)144+ }145+ vals := make([]any, len(cols))146+ ptrs := make([]any, len(cols))147+ for i := range vals {148+ ptrs[i] = &vals[i]149+ }150+ row, blobs := 1, 0151+ for ; rows.Next(); row++ {152+ if row%256 == 0 {153+ if err := ctx.Err(); err != nil {154+ return nil, err155+ }156+ prog := opt.Progress157+ prog.setRows(row)158+ prog.setFrac(int64(row), int64(total))159+ }160+ if err := rows.Scan(ptrs...); err != nil {161+ return nil, sqliteErr(err)162+ }163+ if row >= sheet.MaxRows {164+ b.fits(sheet.Addr{Row: row})165+ continue166+ }167+ for c, v := range vals {168+ if putValue(b, sheet.Addr{Col: c, Row: row}, v) {169+ blobs++170+ }171+ }172+ }173+ if err := rows.Err(); err != nil {174+ return nil, sqliteErr(err)175+ }176+ opt.Progress.setRows(row)177+ var notes []string178+ if blobs > 0 {179+ notes = append(notes, count(blobs, "binary value", "binary values")+" shown as sizes")180+ }181+ s, notes := b.finish(notes)182+ return &Result{Sheet: s, Rows: row, Notes: notes}, nil183+}184+185+// putValue stores a database value, reporting whether it was binary data186+// that could only be described.187+func putValue(b *builder, a sheet.Addr, v any) (blob bool) {188+ switch v := v.(type) {189+ case nil:190+ case int64:191+ b.number(a, float64(v), sheet.Format{}, sheet.Style{})192+ case float64:193+ b.number(a, v, sheet.Format{}, sheet.Style{})194+ case bool:195+ b.boolean(a, v, sheet.Style{})196+ case time.Time:197+ f := sheet.Format{Kind: sheet.FmtDateTime}198+ if h, m, s := v.Clock(); h == 0 && m == 0 && s == 0 && v.Nanosecond() == 0 {199+ f = sheet.Format{Kind: sheet.FmtDate}200+ }201+ b.number(a, serialOf(v), f, sheet.Style{})202+ case string:203+ textOrDate(b, a, v)204+ case []byte:205+ if utf8.Valid(v) {206+ textOrDate(b, a, string(v))207+ return false208+ }209+ b.text(a, fmt.Sprintf("(%s)", count(len(v), "byte", "bytes")), sheet.Format{}, sheet.Style{})210+ return true211+ default:212+ b.text(a, fmt.Sprint(v), sheet.Format{}, sheet.Style{})213+ }214+ return false215+}216+217+// textOrDate stores database text as text, except dates and times such218+// as 2026-09-26, which become dates as they would when typed. Numbers219+// stored as text (zip codes, IDs) stay text.220+func textOrDate(b *builder, a sheet.Addr, s string) {221+ if _, f, ok := sheet.ParseValue(s); ok {222+ switch f.Kind {223+ case sheet.FmtDate, sheet.FmtTime, sheet.FmtDateTime:224+ b.entry(a, s)225+ return226+ }227+ }228+ b.text(a, s, sheet.Format{}, sheet.Style{})229+}230+231+// epoch is day 0 of serial dates.232+var epoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)233+234+// serialOf is t's serial date, reading its wall clock.235+func serialOf(t time.Time) float64 {236+ wall := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)237+ return wall.Sub(epoch).Hours() / 24238+}239+240+// timeOf is the time of a serial date, to the second.241+func timeOf(v float64) time.Time {242+ days := math.Floor(v)243+ secs := math.Round((v - days) * 86400)244+ return epoch.AddDate(0, 0, int(days)).Add(time.Duration(secs) * time.Second)245+}246+247+// sqlColumn is a column of an exported table.248+type sqlColumn struct {249+ name string250+ typ string // INTEGER, REAL or TEXT251+}252+253+// exportSQLite writes the snapshot as a table, its first row naming the254+// columns. An existing table of that name is replaced; the rest of the255+// database is left alone.256+func exportSQLite(ctx context.Context, name string, snap *Snapshot, table string) (*ExportResult, error) {257+ if table == "" {258+ table = TableName(snap.Name)259+ }260+ r := snap.Range261+ cols := make([]sqlColumn, r.To.Col-r.From.Col+1)262+ seen := map[string]bool{}263+ for i := range cols {264+ a := sheet.Addr{Col: r.From.Col + i, Row: r.From.Row}265+ n := strings.TrimSpace(snap.Cells[a].Text())266+ if n == "" {267+ n = "column_" + sheet.ColName(a.Col)268+ }269+ base := n270+ for k := 2; seen[strings.ToLower(n)]; k++ {271+ n = fmt.Sprintf("%s_%d", base, k)272+ }273+ seen[strings.ToLower(n)] = true274+ cols[i] = sqlColumn{name: n, typ: columnType(snap, a.Col)}275+ }276+277+ db, err := sql.Open("sqlite", name)278+ if err != nil {279+ return nil, err280+ }281+ defer db.Close()282+ db.SetMaxOpenConns(1)283+ tx, err := db.BeginTx(ctx, nil)284+ if err != nil {285+ return nil, sqliteErr(err)286+ }287+ defer tx.Rollback()288+ defs := make([]string, len(cols))289+ marks := make([]string, len(cols))290+ for i, c := range cols {291+ defs[i] = quoteIdent(c.name) + " " + c.typ292+ marks[i] = "?"293+ }294+ for _, stmt := range []string{295+ "DROP TABLE IF EXISTS " + quoteIdent(table),296+ "CREATE TABLE " + quoteIdent(table) + " (" + strings.Join(defs, ", ") + ")",297+ } {298+ if _, err := tx.ExecContext(ctx, stmt); err != nil {299+ return nil, sqliteErr(err)300+ }301+ }302+ ins, err := tx.PrepareContext(ctx, "INSERT INTO "+quoteIdent(table)+" VALUES ("+strings.Join(marks, ", ")+")")303+ if err != nil {304+ return nil, sqliteErr(err)305+ }306+ defer ins.Close()307+ args := make([]any, len(cols))308+ n := 0309+ for row := r.From.Row + 1; row <= r.To.Row; row++ {310+ empty := true311+ for i, col := range cols {312+ c, ok := snap.Cells[sheet.Addr{Col: r.From.Col + i, Row: row}]313+ args[i] = nil314+ if ok {315+ args[i] = sqlValue(c, col.typ)316+ empty = false317+ }318+ }319+ if empty {320+ continue321+ }322+ if _, err := ins.ExecContext(ctx, args...); err != nil {323+ return nil, sqliteErr(err)324+ }325+ n++326+ }327+ if err := tx.Commit(); err != nil {328+ return nil, sqliteErr(err)329+ }330+ return &ExportResult{Rows: n}, nil331+}332+333+// columnType picks a column's type from its cells below the header:334+// INTEGER or REAL when every value is a number (or boolean), TEXT when335+// any is text or a date.336+func columnType(snap *Snapshot, col int) string {337+ typ := ""338+ for row := snap.Range.From.Row + 1; row <= snap.Range.To.Row; row++ {339+ c, ok := snap.Cells[sheet.Addr{Col: col, Row: row}]340+ if !ok || c.Value.Kind == sheet.Empty || c.Value.Kind == sheet.Error {341+ continue342+ }343+ switch {344+ case c.Value.Kind == sheet.Text, c.Format.Kind == sheet.FmtDate, c.Format.Kind == sheet.FmtTime,345+ c.Format.Kind == sheet.FmtDateTime, c.Format.Kind == sheet.FmtText:346+ return "TEXT"347+ case c.Value.Kind == sheet.Number && c.Value.Num != math.Trunc(c.Value.Num):348+ typ = "REAL"349+ case typ == "":350+ typ = "INTEGER"351+ }352+ }353+ if typ == "" {354+ return "TEXT"355+ }356+ return typ357+}358+359+// sqlValue is a cell's value for a column of type typ.360+func sqlValue(c SnapCell, typ string) any {361+ v := c.Value362+ switch v.Kind {363+ case sheet.Empty, sheet.Error:364+ return nil365+ case sheet.Bool:366+ if typ == "TEXT" {367+ return v.String()368+ }369+ return int64(v.Num)370+ case sheet.Text:371+ return v.Str372+ }373+ switch typ {374+ case "INTEGER":375+ return int64(v.Num)376+ case "REAL":377+ return v.Num378+ }379+ t := timeOf(v.Num)380+ switch c.Format.Kind {381+ case sheet.FmtDate:382+ return t.Format(time.DateOnly)383+ case sheet.FmtTime:384+ return t.Format(time.TimeOnly)385+ case sheet.FmtDateTime:386+ return t.Format(time.DateTime)387+ }388+ return v.String()389+}390+391+// TableName makes a table name from a file or sheet name: letters,392+// digits and underscores, not starting with a digit.393+func TableName(s string) string {394+ var b strings.Builder395+ for _, r := range s {396+ switch {397+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_', r >= '0' && r <= '9':398+ b.WriteRune(r)399+ default:400+ b.WriteByte('_')401+ }402+ }403+ out := strings.Trim(b.String(), "_")404+ if out == "" {405+ return "sheet"406+ }407+ if out[0] >= '0' && out[0] <= '9' {408+ out = "t_" + out409+ }410+ return out411+}
A internal/fileio/sqlite_test.go +179 -0
@@ -0,0 +1,179 @@
1+package fileio2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "os"8+ "path/filepath"9+ "strings"10+ "testing"11+12+ "012/internal/sheet"13+)14+15+// sqliteFixture makes a database with two tables and a view.16+func sqliteFixture(t *testing.T) string {17+ t.Helper()18+ name := filepath.Join(t.TempDir(), "shop.sqlite")19+ db, err := sql.Open("sqlite", name)20+ if err != nil {21+ t.Fatal(err)22+ }23+ defer db.Close()24+ for _, stmt := range []string{25+ `CREATE TABLE orders (id INTEGER, customer TEXT, total REAL, placed TEXT, zip TEXT, note BLOB)`,26+ `INSERT INTO orders VALUES (1, 'Ada', 12.5, '2026-09-26', '02139', NULL)`,27+ `INSERT INTO orders VALUES (2, '=cmd()', 7, '2026-09-27 14:30:00', '10001', x'00ff10')`,28+ `CREATE TABLE customers (name TEXT, since INTEGER)`,29+ `INSERT INTO customers VALUES ('Ada', 2020)`,30+ `CREATE VIEW big AS SELECT * FROM orders WHERE total > 10`,31+ } {32+ if _, err := db.Exec(stmt); err != nil {33+ t.Fatal(err)34+ }35+ }36+ return name37+}38+39+func TestSQLiteTables(t *testing.T) {40+ name := sqliteFixture(t)41+ ts, err := Tables(context.Background(), name)42+ if err != nil {43+ t.Fatal(err)44+ }45+ var got []string46+ for _, tb := range ts {47+ got = append(got, tb.Name+":"+strings.Join(tb.Cols, "/")+":"+thousands(tb.Rows))48+ }49+ want := "big:id/customer/total/placed/zip/note:1 customers:name/since:1 orders:id/customer/total/placed/zip/note:2"50+ if strings.Join(got, " ") != want {51+ t.Errorf("tables %q", got)52+ }53+ if !ts[0].View {54+ t.Errorf("big isn't a view")55+ }56+}57+58+func TestSQLiteImport(t *testing.T) {59+ name := sqliteFixture(t)60+ ctx := context.Background()61+62+ _, err := Import(ctx, name, Options{})63+ var need *ErrNeedTable64+ if !errors.As(err, &need) || len(need.Tables) != 3 {65+ t.Fatalf("import without a table: %v", err)66+ }67+68+ res, err := Import(ctx, name, Options{Table: "orders"})69+ if err != nil {70+ t.Fatal(err)71+ }72+ s := res.Sheet73+ for cell, want := range map[string]string{74+ "A1": "id", "F1": "note", "A2": "1", "C2": "12.5", "D2": "2026-09-26", "E2": "02139",75+ "B3": "=cmd()", "D3": "9/27/2026 14:30:00", "F3": "(3 bytes)",76+ } {77+ if g := shown(s, addr(t, cell)); g != want {78+ t.Errorf("%s shows %q, want %q", cell, g, want)79+ }80+ }81+ if !s.Cell(addr(t, "A1")).Style.Bold {82+ t.Errorf("header isn't bold")83+ }84+ if res.Rows != 3 || !strings.Contains(strings.Join(res.Notes, ";"), "1 binary value shown as sizes") {85+ t.Errorf("rows %d, notes %q", res.Rows, res.Notes)86+ }87+88+ res, err = Import(ctx, name, Options{Query: "SELECT customer, total*2 AS twice FROM orders ORDER BY id DESC"})89+ if err != nil {90+ t.Fatal(err)91+ }92+ if g := shown(res.Sheet, addr(t, "B1")) + " " + shown(res.Sheet, addr(t, "B3")); g != "twice 25" {93+ t.Errorf("query result %q", g)94+ }95+96+ // Queries can't change the database.97+ if _, err := Import(ctx, name, Options{Query: "DELETE FROM orders"}); err == nil {98+ t.Errorf("DELETE ran")99+ }100+ if _, err := Import(ctx, name, Options{Query: "SELECT nope FROM orders"}); err == nil || !strings.Contains(err.Error(), "no such column: nope") {101+ t.Errorf("bad query error: %v", err)102+ }103+ // A file that isn't a database says so.104+ junk := filepath.Join(t.TempDir(), "junk.db")105+ os.WriteFile(junk, []byte("hello, this is not sqlite at all, not even close......"), 0o644)106+ if _, err := Import(ctx, junk, Options{}); err == nil || !strings.Contains(err.Error(), "not a database") {107+ t.Errorf("junk error: %v", err)108+ }109+ // Opening doesn't create missing files.110+ missing := filepath.Join(t.TempDir(), "missing.sqlite")111+ if _, err := Import(ctx, missing, Options{}); err == nil {112+ t.Errorf("imported a missing file")113+ }114+ if _, err := os.Stat(missing); err == nil {115+ t.Errorf("import created %s", missing)116+ }117+}118+119+func TestSQLiteExportRoundTrip(t *testing.T) {120+ src := build(t, map[string]string{121+ "A1": "Item", "B1": "Qty", "C1": "Price", "D1": "Due", "E1": "", "F1": "Item",122+ "A2": "Rent", "B2": "1", "C2": "$1,450.50", "D2": "10/1/2026", "E2": "x", "F2": "TRUE",123+ "A3": "Food", "B3": "=B2+2", "C3": "612.4", "D3": "9/28/2026",124+ "A5": "Gym", "B5": "4", "C5": "=1/0",125+ })126+ name := sqliteFixture(t)127+ ctx := context.Background()128+ res, err := Export(ctx, name, SQLite, Snap(src, sheet.Rect{}, "my bills!"), ExportOptions{})129+ if err != nil {130+ t.Fatal(err)131+ }132+ if res.Rows != 3 {133+ t.Errorf("wrote %d rows", res.Rows)134+ }135+ db, _ := sql.Open("sqlite", name)136+ defer db.Close()137+ var ddl string138+ db.QueryRow(`SELECT sql FROM sqlite_master WHERE name = 'my_bills'`).Scan(&ddl)139+ want := `CREATE TABLE "my_bills" ("Item" TEXT, "Qty" INTEGER, "Price" REAL, "Due" TEXT, "column_E" TEXT, "Item_2" INTEGER)`140+ if ddl != want {141+ t.Errorf("table\n%s\nwant\n%s", ddl, want)142+ }143+ var n int144+ db.QueryRow(`SELECT count(*) FROM orders`).Scan(&n)145+ if n != 2 {146+ t.Errorf("other tables changed: orders has %d rows", n)147+ }148+149+ back, err := Import(ctx, name, Options{Table: "my_bills"})150+ if err != nil {151+ t.Fatal(err)152+ }153+ for cell, want := range map[string]string{154+ "A2": "Rent", "B3": "3", "C2": "1450.5", "D2": "2026-10-01", "F2": "1", "A4": "Gym", "C4": "",155+ } {156+ if g := shown(back.Sheet, addr(t, cell)); g != want {157+ t.Errorf("%s shows %q, want %q", cell, g, want)158+ }159+ }160+161+ // Exporting a selection writes just it, its first row as the header;162+ // exporting again replaces the table.163+ r := sheet.NewRect(addr(t, "A2"), addr(t, "B3"))164+ if _, err := Export(ctx, name, SQLite, Snap(src, r, "x"), ExportOptions{Table: "my_bills"}); err != nil {165+ t.Fatal(err)166+ }167+ db.QueryRow(`SELECT count(*) FROM my_bills WHERE Rent = 'Food'`).Scan(&n)168+ if n != 1 {169+ t.Errorf("selection export: %d rows", n)170+ }171+}172+173+func TestTableName(t *testing.T) {174+ for in, want := range map[string]string{"budget": "budget", "my bills!": "my_bills", "2026 sales": "t_2026_sales", "": "sheet", "é": "sheet"} {175+ if got := TableName(in); got != want {176+ t.Errorf("TableName(%q) = %q, want %q", in, got, want)177+ }178+ }179+}
A internal/fileio/testdata/fuzz/FuzzReadWK1/6f51e843b7351fb5 +2 -0
A internal/fileio/wk1.go +311 -0
@@ -0,0 +1,311 @@
1+package fileio2+3+import (4+ "bufio"5+ "context"6+ "encoding/binary"7+ "errors"8+ "fmt"9+ "io"10+ "math"11+ "os"12+ "strings"13+14+ "012/internal/sheet"15+)16+17+// Lotus 1-2-3 worksheets (.wks from Release 1A, .wk1 from Release 2) are18+// a stream of little-endian records: a 16-bit opcode, a 16-bit length19+// and the payload. This reader follows the LibreOffice and Gnumeric20+// import filters.21+22+const (23+ wkBOF = 0x0024+ wkEOF = 0x0125+ wkRange = 0x06 // the active area26+ wkColW = 0x08 // column width27+ wkName = 0x0B // a named range28+ wkBlank = 0x0C29+ wkInteger = 0x0D30+ wkNumber = 0x0E31+ wkLabel = 0x0F32+ wkFormula = 0x1033+ wkString = 0x33 // the text result of the formula before it34+)35+36+// wk1Version tells .wks (2,048 rows) from .wk1 (8,192 rows), which37+// encode relative row offsets in formulas with different widths.38+type wk1Version int39+40+const (41+ wksFile wk1Version = iota42+ wk1File43+)44+45+func importWK1(ctx context.Context, name string, prog *Progress) (*Result, error) {46+ f, err := os.Open(name)47+ if err != nil {48+ return nil, err49+ }50+ defer f.Close()51+ var size int6452+ if st, err := f.Stat(); err == nil {53+ size = st.Size()54+ }55+ cr := &countingReader{r: f}56+ s, notes, rows, err := readWK1(ctx, bufio.NewReader(cr), func(cells int) {57+ prog.setRows(cells)58+ prog.setFrac(cr.n, size)59+ })60+ if err != nil {61+ return nil, err62+ }63+ return &Result{Sheet: s, Rows: rows, Notes: notes}, nil64+}65+66+// wk1Reader holds the state of one import.67+type wk1Reader struct {68+ b *builder69+ version wk1Version70+ // pending is a formula whose value was kept, waiting for a STRING71+ // record with its text result.72+ pending sheet.Addr73+ hasString bool74+ style map[sheet.Addr]sheet.Style75+ names int76+ maxRow int77+}78+79+// readWK1 reads a worksheet record by record. progress gets the number80+// of cells read so far.81+func readWK1(ctx context.Context, r io.Reader, progress func(cells int)) (*sheet.Sheet, []string, int, error) {82+ w := &wk1Reader{b: newBuilder(), version: wk1File}83+ var head [4]byte84+ cells := 085+ for i := 0; ; i++ {86+ if _, err := io.ReadFull(r, head[:]); err != nil {87+ if i == 0 {88+ return nil, nil, 0, errors.New("not a Lotus 1-2-3 worksheet: the file is empty")89+ }90+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {91+ break // no EOF record; keep what was read, as 1-2-3 does92+ }93+ return nil, nil, 0, err94+ }95+ op := binary.LittleEndian.Uint16(head[0:])96+ n := binary.LittleEndian.Uint16(head[2:])97+ if i == 0 && (op != wkBOF || n != 2) {98+ return nil, nil, 0, errors.New("not a Lotus 1-2-3 worksheet: it doesn't start with a BOF record")99+ }100+ data := make([]byte, n)101+ if _, err := io.ReadFull(r, data); err != nil {102+ return nil, nil, 0, fmt.Errorf("record %d (opcode 0x%02X) is cut off", i+1, op)103+ }104+ if op == wkEOF {105+ break106+ }107+ if w.record(op, data) {108+ cells++109+ }110+ if i%512 == 0 {111+ if err := ctx.Err(); err != nil {112+ return nil, nil, 0, err113+ }114+ progress(cells)115+ }116+ }117+ progress(cells)118+ var notes []string119+ if w.names > 0 {120+ notes = append(notes, count(w.names, "named range", "named ranges")+" not imported")121+ }122+ s, notes := w.b.finish(notes)123+ return s, notes, w.maxRow + 1, nil124+}125+126+// record handles one record, reporting whether it was a cell.127+func (w *wk1Reader) record(op uint16, d []byte) bool {128+ switch op {129+ case wkBOF:130+ if len(d) >= 2 && binary.LittleEndian.Uint16(d) == 0x0404 {131+ w.version = wksFile132+ }133+ return false134+ case wkColW:135+ if len(d) >= 3 {136+ col := int(binary.LittleEndian.Uint16(d))137+ if col < sheet.MaxCols && d[2] > 0 {138+ w.b.s.SetColWidth(col, int(d[2])+1)139+ }140+ }141+ return false142+ case wkName:143+ w.names++144+ return false145+ case wkBlank, wkInteger, wkNumber, wkLabel, wkFormula, wkString:146+ default:147+ return false148+ }149+ if len(d) < 5 {150+ return false151+ }152+ format := d[0]153+ a := sheet.Addr{Col: int(binary.LittleEndian.Uint16(d[1:])), Row: int(binary.LittleEndian.Uint16(d[3:]))}154+ body := d[5:]155+ f := lotusFormat(format)156+ w.maxRow = max(w.maxRow, a.Row)157+ switch op {158+ case wkBlank:159+ w.b.put(a, "", f, sheet.Style{})160+ case wkInteger:161+ if len(body) < 2 {162+ return false163+ }164+ w.b.number(a, float64(int16(binary.LittleEndian.Uint16(body))), f, sheet.Style{})165+ case wkNumber:166+ if len(body) < 8 {167+ return false168+ }169+ v := math.Float64frombits(binary.LittleEndian.Uint64(body))170+ w.b.number(a, fromExcelSerial(v, f), f, sheet.Style{})171+ case wkLabel:172+ w.label(a, body)173+ case wkFormula:174+ if len(body) < 10 {175+ return false176+ }177+ v := math.Float64frombits(binary.LittleEndian.Uint64(body))178+ size := int(binary.LittleEndian.Uint16(body[8:]))179+ code := body[10:]180+ if size < len(code) {181+ code = code[:size]182+ }183+ w.hasString = false184+ text, ok := lotusFormula(code, a, w.version)185+ keep := func() {186+ w.b.number(a, fromExcelSerial(v, f), f, sheet.Style{})187+ w.pending, w.hasString = a, true188+ }189+ if !ok {190+ w.b.kept(a, text, keep)191+ return true192+ }193+ w.b.formula(a, "="+text, f, sheet.Style{}, keep)194+ case wkString:195+ if w.hasString && w.pending == a {196+ w.b.text(a, lics(cstring(body)), sheet.Format{}, sheet.Style{})197+ }198+ w.hasString = false199+ return false200+ }201+ return true202+}203+204+// label stores a label, whose first character sets its alignment: '205+// left, " right, ^ centered, \ repeated to fill the cell.206+func (w *wk1Reader) label(a sheet.Addr, body []byte) {207+ s := lics(cstring(body))208+ if s == "" {209+ return210+ }211+ var st sheet.Style212+ switch s[0] {213+ case '\'':214+ s = s[1:]215+ case '"':216+ s, st.Align = s[1:], sheet.AlignRight217+ case '^':218+ s, st.Align = s[1:], sheet.AlignCenter219+ case '\\':220+ s = s[1:]221+ if s != "" {222+ width := w.b.s.ColWidth(a.Col) - 1223+ s = strings.Repeat(s, max(width/len([]rune(s)), 1))224+ }225+ case '|':226+ s = s[1:]227+ }228+ w.b.text(a, s, sheet.Format{}, st)229+}230+231+// cstring is the NUL-terminated string at the start of b.232+func cstring(b []byte) string {233+ if i := indexByte(b, 0); i >= 0 {234+ b = b[:i]235+ }236+ return string(b)237+}238+239+func indexByte(b []byte, c byte) int {240+ for i, x := range b {241+ if x == c {242+ return i243+ }244+ }245+ return -1246+}247+248+// lics decodes the Lotus International Character Set as Latin-1, which249+// it matches for the accented letters people used.250+func lics(s string) string {251+ ascii := true252+ for i := 0; i < len(s); i++ {253+ if s[i] >= 0x80 {254+ ascii = false255+ break256+ }257+ }258+ if ascii {259+ return s260+ }261+ r := make([]rune, len(s))262+ for i := 0; i < len(s); i++ {263+ r[i] = rune(s[i])264+ }265+ return string(r)266+}267+268+// lotusFormat maps a cell's format byte: bit 7 is protection, bits 4-6269+// the type and bits 0-3 decimals, or for type 7 a special format.270+func lotusFormat(b byte) sheet.Format {271+ typ, n := (b>>4)&7, int(b&0x0F)272+ dec := min(n, sheet.MaxDecimals)273+ switch typ {274+ case 0: // fixed275+ pat := "0"276+ if dec > 0 {277+ pat += "." + strings.Repeat("0", dec)278+ }279+ return sheet.Format{Kind: sheet.FmtCustom, Pattern: pat}280+ case 1:281+ return sheet.Format{Kind: sheet.FmtScientific, Decimals: dec}282+ case 2:283+ return sheet.Format{Kind: sheet.FmtCurrency, Decimals: dec}284+ case 3:285+ return sheet.Format{Kind: sheet.FmtPercent, Decimals: dec}286+ case 4:287+ return sheet.Format{Kind: sheet.FmtNumber, Decimals: dec}288+ case 7:289+ switch n {290+ case 2:291+ return sheet.Format{Kind: sheet.FmtDate, Pattern: "d-mmm-yy"}292+ case 3:293+ return sheet.Format{Kind: sheet.FmtDate, Pattern: "d-mmm"}294+ case 4:295+ return sheet.Format{Kind: sheet.FmtDate, Pattern: "mmm-yy"}296+ case 7:297+ return sheet.Format{Kind: sheet.FmtTime, Pattern: "h:mm:ss AM/PM"}298+ case 8:299+ return sheet.Format{Kind: sheet.FmtTime, Pattern: "h:mm AM/PM"}300+ case 9:301+ return sheet.Format{Kind: sheet.FmtDate, Pattern: "mm/dd/yy"}302+ case 10:303+ return sheet.Format{Kind: sheet.FmtDate, Pattern: "mm/dd"}304+ case 11:305+ return sheet.Format{Kind: sheet.FmtTime, Pattern: "hh:mm:ss"}306+ case 12:307+ return sheet.Format{Kind: sheet.FmtTime, Pattern: "hh:mm"}308+ }309+ }310+ return sheet.Format{} // general, +/-, text (shows formulas), hidden, default311+}
A internal/fileio/wk1_test.go +236 -0
@@ -0,0 +1,236 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "encoding/binary"7+ "math"8+ "os"9+ "path/filepath"10+ "strings"11+ "testing"12+13+ "012/internal/sheet"14+)15+16+// wk1 encodes a worksheet for tests: 1-2-3 files don't ship with 012.17+type wk1 struct{ bytes.Buffer }18+19+func newWK1(version uint16) *wk1 {20+ w := &wk1{}21+ w.record(wkBOF, le16(version))22+ return w23+}24+25+func le16(v uint16) []byte { return binary.LittleEndian.AppendUint16(nil, v) }26+27+func le64(f float64) []byte { return binary.LittleEndian.AppendUint64(nil, math.Float64bits(f)) }28+29+func (w *wk1) record(op uint16, payload ...[]byte) {30+ body := bytes.Join(payload, nil)31+ w.Write(le16(op))32+ w.Write(le16(uint16(len(body))))33+ w.Write(body)34+}35+36+func cellHead(format byte, a string) []byte {37+ ad, _ := sheet.ParseAddr(a)38+ return append([]byte{format}, append(le16(uint16(ad.Col)), le16(uint16(ad.Row))...)...)39+}40+41+func (w *wk1) label(a, text string) { w.record(wkLabel, cellHead(0xFF, a), []byte(text+"\x00")) }42+func (w *wk1) number(a string, format byte, v float64) {43+ w.record(wkNumber, cellHead(format, a), le64(v))44+}45+func (w *wk1) integer(a string, v int16) { w.record(wkInteger, cellHead(0xFF, a), le16(uint16(v))) }46+func (w *wk1) formula(a string, format byte, cached float64, code []byte) {47+ w.record(wkFormula, cellHead(format, a), le64(cached), le16(uint16(len(code))), code)48+}49+func (w *wk1) colWidth(col uint16, width byte) { w.record(wkColW, le16(col), []byte{width}) }50+func (w *wk1) end() []byte { w.record(wkEOF); return w.Bytes() }51+52+// Formula bytecode.53+func fnum(v float64) []byte { return append([]byte{0x00}, le64(v)...) }54+func fint(v int16) []byte { return append([]byte{0x05}, le16(uint16(v))...) }55+func fstr(s string) []byte { return append(append([]byte{0x06}, s...), 0) }56+57+// fref encodes a reference to target from the formula cell at; relative58+// coordinates are stored as offsets with bit 15 set.59+func fref(at, target string, absCol, absRow bool) []byte {60+ a, _ := sheet.ParseAddr(at)61+ t, _ := sheet.ParseAddr(target)62+ col := uint16(t.Col)63+ if !absCol {64+ col = 0x8000 | uint16(uint8(int8(t.Col-a.Col)))65+ }66+ row := uint16(t.Row)67+ if !absRow {68+ row = 0x8000 | uint16(t.Row-a.Row)&0x1FFF69+ }70+ return append(le16(col), le16(row)...)71+}72+func fcell(at, target string) []byte { return append([]byte{0x01}, fref(at, target, false, false)...) }73+func frange(at, from, to string) []byte {74+ return append(append([]byte{0x02}, fref(at, from, false, false)...), fref(at, to, false, false)...)75+}76+func code(parts ...[]byte) []byte { return append(bytes.Join(parts, nil), 0x03) }77+78+func TestWK1Formulas(t *testing.T) {79+ op := func(b byte) []byte { return []byte{b} }80+ va := func(b byte, n byte) []byte { return []byte{b, n} }81+ for _, tc := range []struct {82+ name string83+ code []byte84+ want string85+ ok bool86+ }{87+ {"reference", code(fcell("C3", "A1")), "A1", true},88+ {"absolute", code(append([]byte{0x01}, fref("C3", "B2", true, true)...)), "$B$2", true},89+ {"mixed", code(append([]byte{0x01}, fref("C3", "B2", true, false)...)), "$B2", true},90+ {"below", code(fcell("A1", "D9")), "D9", true},91+ {"precedence", code(fcell("C3", "A1"), fnum(2), op(0x09), fint(3), op(0x0B)), "(A1+2)*3", true},92+ {"no extra parentheses", code(fcell("C3", "A1"), fnum(2), fint(3), op(0x0B), op(0x09)), "A1+2*3", true},93+ {"left associative", code(fint(1), fint(2), fint(3), op(0x0A), op(0x0A)), "1-(2-3)", true},94+ {"written parentheses", code(fint(1), fint(2), op(0x09), op(0x04)), "(1+2)", true},95+ {"power and negation", code(fint(2), op(0x08), fint(2), op(0x0D)), "-2^2", true},96+ {"negated power", code(fint(2), fint(2), op(0x0D), op(0x08)), "-(2^2)", true},97+ {"comparison and logic", code(fcell("C3", "A1"), fint(1), op(0x13), fcell("C3", "B1"), fint(2), op(0x12), op(0x14)), "A1>1#AND#B1<2", true},98+ {"not", code(fcell("C3", "A1"), op(0x16)), "#NOT#A1", true},99+ {"strings", code(fstr(`say "hi"`), fcell("C3", "A1"), op(0x18)), `"say ""hi"""&A1`, true},100+ {"sum", code(frange("C3", "A1", "A2"), fcell("C3", "B1"), va(0x50, 2)), "SUM(A1:A2,B1)", true},101+ {"avg count min max", code(frange("C3", "A1", "B2"), va(0x51, 1), frange("C3", "A1", "B2"), va(0x52, 1), op(0x09)), "AVERAGE(A1:B2)+COUNTA(A1:B2)", true},102+ {"if", code(fcell("C3", "A1"), fint(0), op(0x13), fstr("yes"), fstr("no"), op(0x3B)), `IF(A1>0,"yes","no")`, true},103+ {"round", code(fnum(2.345), fint(2), op(0x3F)), "ROUND(2.345,2)", true},104+ {"int truncates", code(fnum(-2.5), op(0x22)), "TRUNC(-2.5)", true},105+ {"pi", code(op(0x26), fint(2), op(0x0B)), "PI()*2", true},106+ {"pmt reorders", code(fint(1000), fnum(0.01), fint(12), op(0x38)), "PMT(0.01,12,-1000)", true},107+ {"choose counts from 0", code(fcell("C3", "A1"), fint(10), fint(20), va(0x30, 3)), "CHOOSE((A1)+1,10,20)", true},108+ {"vlookup offset", code(fcell("C3", "A1"), frange("C3", "D1", "E9"), fint(1), op(0x55)), "VLOOKUP(A1,D1:E9,2)", true},109+ {"date years since 1900", code(fint(126), fint(9), fint(26), op(0x36)), "DATE(1900+126,9,26)", true},110+ {"year", code(fcell("C3", "A1"), op(0x3E)), "(YEAR(A1)-1900)", true},111+ {"no equivalent", code(fcell("C3", "A1"), fint(2), op(0x48)), "@STRING(A1,2)", false},112+ {"database function", code(frange("C3", "A1", "B9"), fint(1), frange("C3", "D1", "D2"), va(0x5B, 3)), "@DSUM(A1:B9,1,D1:D2)", false},113+ {"unknown opcode", code([]byte{0xEE}), "", false},114+ {"stack underflow", code(op(0x09)), "", false},115+ {"cut off", []byte{0x00, 1, 2}, "", false},116+ {"off the sheet", code(fcell("A1", "A1")[:1], []byte{0xFB, 0x80, 0x00, 0x80}), "#REF!", false},117+ } {118+ t.Run(tc.name, func(t *testing.T) {119+ got, ok := lotusFormula(tc.code, addr(t, "C3"), wk1File)120+ if tc.name == "below" {121+ got, ok = lotusFormula(tc.code, addr(t, "A1"), wk1File)122+ }123+ if got != tc.want || ok != tc.ok {124+ t.Errorf("= %q, %v; want %q, %v", got, ok, tc.want, tc.ok)125+ }126+ if ok {127+ if _, err := sheet.Parse("=" + got); err != nil {128+ t.Errorf("%q doesn't parse: %v", got, err)129+ }130+ }131+ })132+ }133+}134+135+func TestWK1Import(t *testing.T) {136+ w := newWK1(0x0406)137+ w.record(wkRange, le16(0), le16(0), le16(3), le16(6))138+ w.colWidth(0, 14)139+ w.label("A1", "'Budget 1986")140+ w.label("B1", `"Right`)141+ w.label("C1", "^Mid")142+ w.label("D1", `\-`)143+ w.label("A2", "'123")144+ w.label("A3", "caf\xe9")145+ w.integer("B2", 42)146+ w.number("B3", 0x22, 1450.5) // currency, 2 decimals147+ w.number("B4", 0x31, 0.125) // percent, 1 decimal148+ w.number("B5", 0x72, 31679) // DD-MMM-YY149+ w.number("B6", 0x02, 3.14159) // fixed, 2 decimals150+ w.number("C2", 0x42, 1234567) // comma, 2 decimals151+ w.formula("C3", 0x22, 1492.5, code(fcell("C3", "B2"), fcell("C3", "B3"), op9())) // =B2+B3152+ w.formula("C4", 0xFF, 7, code(fcell("C4", "A1"), fint(2), []byte{0x48})) // @STRING: kept153+ w.record(wkString, cellHead(0xFF, "C4"), []byte("1,986.00\x00"))154+ w.formula("C5", 0xFF, 5, code(fint(5), []byte{0x7A})) // unknown opcode: kept155+ w.record(wkName, make([]byte, 24))156+ name := filepath.Join(t.TempDir(), "budget.wk1")157+ os.WriteFile(name, w.end(), 0o644)158+159+ res, err := Import(context.Background(), name, Options{Progress: NewProgress()})160+ if err != nil {161+ t.Fatal(err)162+ }163+ s := res.Sheet164+ for cell, want := range map[string]string{165+ "A1": "Budget 1986", "B1": "Right", "C1": "Mid", "D1": "---------", "A2": "123", "A3": "café",166+ "B2": "42", "B3": "$1,450.50", "B4": "12.5%", "B5": "24-Sep-86", "B6": "3.14", "C2": "1,234,567.00",167+ "C3": "$1,492.50", "C4": "1,986.00", "C5": "5",168+ } {169+ if g := shown(s, addr(t, cell)); g != want {170+ t.Errorf("%s shows %q, want %q", cell, g, want)171+ }172+ }173+ if g := input(s, addr(t, "C3")); g != "=B2+B3" {174+ t.Errorf("C3 input %q", g)175+ }176+ if g := input(s, addr(t, "A2")); g != "'123" {177+ t.Errorf("A2 input %q", g)178+ }179+ if al := s.Cell(addr(t, "B1")).Style.Align; al != sheet.AlignRight {180+ t.Errorf("B1 align %v", al)181+ }182+ if al := s.Cell(addr(t, "C1")).Style.Align; al != sheet.AlignCenter {183+ t.Errorf("C1 align %v", al)184+ }185+ if w := s.ColWidth(0); w != 15 {186+ t.Errorf("A width %d", w)187+ }188+ notes := strings.Join(res.Notes, "; ")189+ for _, want := range []string{"1 named range not imported", "2 formulas kept as values, e.g. C4 @STRING(A1,2)"} {190+ if !strings.Contains(notes, want) {191+ t.Errorf("notes %q, want %q", notes, want)192+ }193+ }194+}195+196+func op9() []byte { return []byte{0x09} }197+198+func TestWK1Errors(t *testing.T) {199+ for _, tc := range []struct {200+ name string201+ data []byte202+ want string203+ }{204+ {"empty", nil, "the file is empty"},205+ {"not lotus", []byte("PK\x03\x04 a zip file"), "doesn't start with a BOF record"},206+ {"cut off", append(newWK1(0x0406).Bytes(), 0x0F, 0x00, 0x20, 0x00, 1, 2), "record 2 (opcode 0x0F) is cut off"},207+ } {208+ t.Run(tc.name, func(t *testing.T) {209+ _, _, _, err := readWK1(context.Background(), bytes.NewReader(tc.data), func(int) {})210+ if err == nil || !strings.Contains(err.Error(), tc.want) {211+ t.Errorf("error %v, want %q", err, tc.want)212+ }213+ })214+ }215+ // A file without an EOF record keeps what was read.216+ w := newWK1(0x0404)217+ w.integer("A1", 7)218+ s, _, _, err := readWK1(context.Background(), bytes.NewReader(w.Bytes()), func(int) {})219+ if err != nil || shown(s, addr(t, "A1")) != "7" {220+ t.Errorf("no EOF: %v", err)221+ }222+}223+224+func FuzzReadWK1(f *testing.F) {225+ w := newWK1(0x0406)226+ w.label("A1", "'x")227+ w.number("B1", 0x22, 2)228+ w.formula("C1", 0xFF, 3, code(fcell("C1", "B1"), fint(1), op9()))229+ w.formula("C2", 0xFF, 3, code(frange("C2", "A1", "B1"), []byte{0x50, 1}))230+ f.Add(w.end())231+ f.Add(newWK1(0x0404).end())232+ f.Fuzz(func(t *testing.T, data []byte) {233+ readWK1(context.Background(), bytes.NewReader(data), func(int) {})234+ lotusFormula(data, sheet.Addr{Col: 3, Row: 3}, wk1File)235+ })236+}
A internal/fileio/wk1formula.go +361 -0
@@ -0,0 +1,361 @@
1+package fileio2+3+import (4+ "encoding/binary"5+ "math"6+ "strconv"7+ "strings"8+9+ "012/internal/sheet"10+)11+12+// Lotus formulas are stored as reverse Polish bytecode. lotusFormula13+// rebuilds the expression in 012's syntax, adding parentheses where 012's14+// precedence needs them. It reports false, with the formula written as15+// far as possible in 1-2-3's own spelling, when something has no16+// equivalent; the importer then keeps the cell's value.17+18+// expr is a subexpression and how tightly it binds, using 012's binding19+// powers (see sheet/parse.go), so parentheses go exactly where needed.20+type expr struct {21+ text string22+ power int23+}24+25+const (26+ atomPower = 10027+ unaryPower = 7528+ notPower = 2029+ comparePower = 3030+)31+32+var lotusBinary = map[byte]struct {33+ op string34+ power int35+}{36+ 0x09: {"+", 40}, 0x0A: {"-", 40}, 0x0B: {"*", 50}, 0x0C: {"/", 50}, 0x0D: {"^", 70},37+ 0x0E: {"=", comparePower}, 0x0F: {"<>", comparePower}, 0x10: {"<=", comparePower},38+ 0x11: {">=", comparePower}, 0x12: {"<", comparePower}, 0x13: {">", comparePower},39+ 0x14: {"#AND#", 10}, 0x15: {"#OR#", 10}, 0x18: {"&", 35},40+}41+42+// lotusFunc is a 1-2-3 function: its name, its argument count (-1 when a43+// count byte follows the opcode), and how to write it in 012's syntax.44+// A nil write means 012 has no equivalent.45+type lotusFunc struct {46+ name string47+ args int48+ write func(args []string) string49+}50+51+// call writes a 012 function call.52+func call(name string) func([]string) string {53+ return func(args []string) string { return name + "(" + strings.Join(args, ",") + ")" }54+}55+56+// plusOne adds one to a 0-based 1-2-3 argument, as 012 counts from 1.57+func plusOne(s string) string {58+ if v, err := strconv.ParseFloat(s, 64); err == nil {59+ return numInput(v + 1)60+ }61+ return "(" + s + ")+1"62+}63+64+var lotusFuncs = map[byte]lotusFunc{65+ 0x1F: {"NA", 0, call("NA")},66+ 0x20: {"ERR", 0, nil},67+ 0x21: {"ABS", 1, call("ABS")},68+ 0x22: {"INT", 1, call("TRUNC")},69+ 0x23: {"SQRT", 1, call("SQRT")},70+ 0x24: {"LOG", 1, call("LOG10")},71+ 0x25: {"LN", 1, call("LN")},72+ 0x26: {"PI", 0, call("PI")},73+ 0x27: {"SIN", 1, nil}, 0x28: {"COS", 1, nil}, 0x29: {"TAN", 1, nil},74+ 0x2A: {"ATAN2", 2, nil}, 0x2B: {"ATAN", 1, nil}, 0x2C: {"ASIN", 1, nil}, 0x2D: {"ACOS", 1, nil},75+ 0x2E: {"EXP", 1, call("EXP")},76+ 0x2F: {"MOD", 2, nil}, // 1-2-3 keeps the dividend's sign, Sheets the divisor's77+ 0x30: {"CHOOSE", -1, func(a []string) string {78+ return call("CHOOSE")(append([]string{plusOne(a[0])}, a[1:]...))79+ }},80+ 0x31: {"ISNA", 1, call("ISNA")},81+ 0x32: {"ISERR", 1, call("ISERROR")},82+ 0x33: {"FALSE", 0, call("FALSE")},83+ 0x34: {"TRUE", 0, call("TRUE")},84+ 0x35: {"RAND", 0, call("RAND")},85+ 0x36: {"DATE", 3, func(a []string) string { // years since 190086+ return call("DATE")([]string{"1900+" + paren(a[0]), a[1], a[2]})87+ }},88+ 0x37: {"TODAY", 0, call("TODAY")},89+ // PMT, PV and FV take (amount, rate, term); Sheets' take the rate90+ // and term first, and a present value that is paid out is negative.91+ 0x38: {"PMT", 3, func(a []string) string { return call("PMT")([]string{a[1], a[2], "-" + paren(a[0])}) }},92+ 0x39: {"PV", 3, func(a []string) string { return call("PV")([]string{a[1], a[2], "-" + paren(a[0])}) }},93+ 0x3A: {"FV", 3, func(a []string) string { return call("FV")([]string{a[1], a[2], "-" + paren(a[0])}) }},94+ 0x3B: {"IF", 3, call("IF")},95+ 0x3C: {"DAY", 1, call("DAY")},96+ 0x3D: {"MONTH", 1, call("MONTH")},97+ 0x3E: {"YEAR", 1, func(a []string) string { return "(" + call("YEAR")(a) + "-1900)" }},98+ 0x3F: {"ROUND", 2, call("ROUND")},99+ 0x40: {"TIME", 3, call("TIME")},100+ 0x41: {"HOUR", 1, call("HOUR")},101+ 0x42: {"MINUTE", 1, call("MINUTE")},102+ 0x43: {"SECOND", 1, call("SECOND")},103+ 0x44: {"ISNUMBER", 1, call("ISNUMBER")},104+ 0x45: {"ISSTRING", 1, call("ISTEXT")},105+ 0x46: {"LENGTH", 1, call("LEN")},106+ 0x47: {"VALUE", 1, call("VALUE")},107+ 0x48: {"STRING", 2, nil},108+ 0x49: {"MID", 3, func(a []string) string { return call("MID")([]string{a[0], plusOne(a[1]), a[2]}) }},109+ 0x4A: {"CHAR", 1, nil},110+ 0x4B: {"CODE", 1, nil},111+ 0x4C: {"FIND", 3, func(a []string) string {112+ return "(" + call("FIND")([]string{a[0], a[1], plusOne(a[2])}) + "-1)"113+ }},114+ 0x4D: {"DATEVALUE", 1, call("DATEVALUE")},115+ 0x4E: {"TIMEVALUE", 1, call("TIMEVALUE")},116+ 0x4F: {"CELLPOINTER", 1, nil},117+ 0x50: {"SUM", -1, call("SUM")},118+ 0x51: {"AVG", -1, call("AVERAGE")},119+ 0x52: {"COUNT", -1, call("COUNTA")},120+ 0x53: {"MIN", -1, call("MIN")},121+ 0x54: {"MAX", -1, call("MAX")},122+ 0x55: {"VLOOKUP", 3, func(a []string) string { return call("VLOOKUP")([]string{a[0], a[1], plusOne(a[2])}) }},123+ 0x56: {"NPV", 2, call("NPV")},124+ 0x57: {"VAR", -1, call("VARP")},125+ 0x58: {"STD", -1, call("STDEVP")},126+ 0x59: {"IRR", 2, func(a []string) string { return call("IRR")([]string{a[1], a[0]}) }},127+ 0x5A: {"HLOOKUP", 3, func(a []string) string { return call("HLOOKUP")([]string{a[0], a[1], plusOne(a[2])}) }},128+ 0x5B: {"DSUM", -1, nil}, 0x5C: {"DAVG", -1, nil}, 0x5D: {"DCNT", -1, nil}, 0x5E: {"DMIN", -1, nil},129+ 0x5F: {"DMAX", -1, nil}, 0x60: {"DVAR", -1, nil}, 0x61: {"DSTD", -1, nil},130+ 0x62: {"INDEX", -1, func(a []string) string {131+ if len(a) != 3 {132+ return ""133+ }134+ return call("INDEX")([]string{a[0], plusOne(a[2]), plusOne(a[1])})135+ }},136+ 0x63: {"COLS", 1, call("COLUMNS")},137+ 0x64: {"ROWS", 1, call("ROWS")},138+ 0x65: {"REPEAT", 2, call("REPT")},139+ 0x66: {"UPPER", 1, call("UPPER")},140+ 0x67: {"LOWER", 1, call("LOWER")},141+ 0x68: {"LEFT", 2, call("LEFT")},142+ 0x69: {"RIGHT", 2, call("RIGHT")},143+ 0x6A: {"REPLACE", 4, func(a []string) string {144+ return call("REPLACE")([]string{a[0], plusOne(a[1]), a[2], a[3]})145+ }},146+ 0x6B: {"PROPER", 1, call("PROPER")},147+ 0x6C: {"CELL", 2, nil},148+ 0x6D: {"TRIM", 1, call("TRIM")},149+ 0x6E: {"CLEAN", 1, nil},150+ 0x6F: {"S", 1, nil},151+ 0x70: {"N", 1, nil},152+ 0x71: {"EXACT", 2, call("EXACT")},153+ 0x72: {"APP", 1, nil},154+ 0x73: {"@", 1, nil},155+ 0x74: {"RATE", 3, nil},156+ 0x75: {"TERM", 3, nil},157+ 0x76: {"CTERM", 3, nil},158+ 0x77: {"SLN", 3, nil},159+ 0x78: {"SYD", 4, nil},160+ 0x79: {"DDB", 4, nil},161+}162+163+func paren(s string) string {164+ if _, err := strconv.ParseFloat(s, 64); err == nil {165+ return s166+ }167+ if isRef(s) {168+ return s169+ }170+ return "(" + s + ")"171+}172+173+func isRef(s string) bool {174+ _, ok := sheet.ParseAddr(strings.ReplaceAll(s, "$", ""))175+ return ok176+}177+178+// lotusRef decodes a cell reference in a formula at cell at. Bit 15 of179+// each coordinate marks it relative, stored as a two's complement offset180+// from the formula's cell: 8 bits for columns, 11 (.wks) or 13 (.wk1)181+// for rows.182+func lotusRef(col, row uint16, at sheet.Addr, v wk1Version) (string, bool) {183+ a := sheet.Addr{}184+ ref := ""185+ if col&0x8000 != 0 {186+ a.Col = at.Col + int(int8(col&0xFF))187+ } else {188+ a.Col = int(col & 0xFF)189+ ref += "$"190+ }191+ ref += sheet.ColName(max(a.Col, 0))192+ rowBits := uint(13)193+ if v == wksFile {194+ rowBits = 11195+ }196+ mask := uint16(1)<<rowBits - 1197+ if row&0x8000 != 0 {198+ off := int(row & mask)199+ if off&(1<<(rowBits-1)) != 0 {200+ off -= 1 << rowBits201+ }202+ a.Row = at.Row + off203+ } else {204+ a.Row = int(row & mask)205+ ref += "$"206+ }207+ if !a.Valid() {208+ return "#REF!", false209+ }210+ return ref + strconv.Itoa(a.Row+1), true211+}212+213+// lotusFormula decodes the bytecode of the formula in cell at.214+func lotusFormula(code []byte, at sheet.Addr, v wk1Version) (string, bool) {215+ var stack []expr216+ ok := true217+ pop := func() (expr, bool) {218+ if len(stack) == 0 {219+ return expr{}, false220+ }221+ e := stack[len(stack)-1]222+ stack = stack[:len(stack)-1]223+ return e, true224+ }225+ push := func(text string, power int) { stack = append(stack, expr{text, power}) }226+ u16 := func(i int) uint16 { return binary.LittleEndian.Uint16(code[i:]) }227+228+ for i := 0; i < len(code); {229+ op := code[i]230+ switch {231+ case op == 0x00: // number232+ if i+9 > len(code) {233+ return "", false234+ }235+ f := math.Float64frombits(binary.LittleEndian.Uint64(code[i+1:]))236+ if math.IsNaN(f) || math.IsInf(f, 0) {237+ return "", false238+ }239+ if f < 0 {240+ push(numInput(f), unaryPower)241+ } else {242+ push(numInput(f), atomPower)243+ }244+ i += 9245+ case op == 0x01: // cell246+ if i+5 > len(code) {247+ return "", false248+ }249+ ref, good := lotusRef(u16(i+1), u16(i+3), at, v)250+ ok = ok && good251+ push(ref, atomPower)252+ i += 5253+ case op == 0x02: // range254+ if i+9 > len(code) {255+ return "", false256+ }257+ from, good1 := lotusRef(u16(i+1), u16(i+3), at, v)258+ to, good2 := lotusRef(u16(i+5), u16(i+7), at, v)259+ ok = ok && good1 && good2260+ push(from+":"+to, atomPower)261+ i += 9262+ case op == 0x03: // end263+ i = len(code)264+ case op == 0x04: // parentheses, as written265+ e, good := pop()266+ if !good {267+ return "", false268+ }269+ push("("+e.text+")", atomPower)270+ i++271+ case op == 0x05: // integer272+ if i+3 > len(code) {273+ return "", false274+ }275+ n := int16(u16(i + 1))276+ if n < 0 {277+ push(strconv.Itoa(int(n)), unaryPower)278+ } else {279+ push(strconv.Itoa(int(n)), atomPower)280+ }281+ i += 3282+ case op == 0x06: // string283+ end := indexByte(code[i+1:], 0)284+ if end < 0 {285+ return "", false286+ }287+ s := lics(string(code[i+1 : i+1+end]))288+ push(`"`+strings.ReplaceAll(s, `"`, `""`)+`"`, atomPower)289+ i += end + 2290+ case op == 0x08 || op == 0x17 || op == 0x16: // - + #NOT#291+ x, good := pop()292+ if !good {293+ return "", false294+ }295+ sign, power := "-", unaryPower296+ switch op {297+ case 0x17:298+ sign = "+"299+ case 0x16:300+ sign, power = "#NOT#", notPower301+ }302+ if x.power < power {303+ x.text = "(" + x.text + ")"304+ }305+ push(sign+x.text, power)306+ i++307+ case lotusBinary[op].op != "":308+ bin := lotusBinary[op]309+ r, good1 := pop()310+ l, good2 := pop()311+ if !good1 || !good2 {312+ return "", false313+ }314+ if l.power < bin.power {315+ l.text = "(" + l.text + ")"316+ }317+ if r.power <= bin.power {318+ r.text = "(" + r.text + ")"319+ }320+ push(l.text+bin.op+r.text, bin.power)321+ i++322+ default:323+ fn, known := lotusFuncs[op]324+ if !known {325+ return "", false326+ }327+ n, size := fn.args, 1328+ if n < 0 {329+ if i+1 >= len(code) {330+ return "", false331+ }332+ n, size = int(code[i+1]), 2333+ }334+ if n > len(stack) {335+ return "", false336+ }337+ args := make([]string, n)338+ for k := range args {339+ args[k] = stack[len(stack)-n+k].text340+ }341+ stack = stack[:len(stack)-n]342+ text := ""343+ if fn.write != nil && (fn.args >= 0 || n > 0) {344+ text = fn.write(args)345+ }346+ if text == "" {347+ ok = false348+ text = "@" + fn.name349+ if n > 0 {350+ text += "(" + strings.Join(args, ",") + ")"351+ }352+ }353+ push(text, atomPower)354+ i += size355+ }356+ }357+ if len(stack) != 1 {358+ return "", false359+ }360+ return stack[0].text, ok361+}
A internal/fileio/xlsx.go +293 -0
@@ -0,0 +1,293 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "math"7+ "strconv"8+ "strings"9+10+ "github.com/xuri/excelize/v2"11+12+ "012/internal/sheet"13+)14+15+// Column widths: Excel measures in characters of its default font, 01216+// in terminal columns including one of padding.17+const (18+ excelDefaultWidth = 8.4319+ excelPadding = 120+)21+22+func importXLSX(ctx context.Context, name string, prog *Progress) (*Result, error) {23+ x, err := excelize.OpenFile(name)24+ if err != nil {25+ return nil, err26+ }27+ defer x.Close()28+ sheets := x.GetSheetList()29+ if len(sheets) == 0 {30+ return nil, fmt.Errorf("the workbook has no sheets")31+ }32+ ws := sheets[0]33+ var notes []string34+ if n := len(sheets) - 1; n > 0 {35+ notes = append(notes, fmt.Sprintf("imported the first sheet, %s; %s not imported (%s)",36+ ws, count(n, "other sheet", "other sheets"), strings.Join(sheets[1:], ", ")))37+ }38+39+ total := 040+ if dim, err := x.GetSheetDimension(ws); err == nil {41+ if _, to, ok := strings.Cut(dim, ":"); ok {42+ if _, r, err := excelize.CellNameToCoordinates(to); err == nil {43+ total = r44+ }45+ }46+ }47+48+ b := newBuilder()49+ styles := map[int]xlsxStyle{}50+ rows, err := x.Rows(ws)51+ if err != nil {52+ return nil, err53+ }54+ defer rows.Close()55+ row := 056+ for ; rows.Next(); row++ {57+ if row%128 == 0 {58+ if err := ctx.Err(); err != nil {59+ return nil, err60+ }61+ prog.setRows(row)62+ prog.setFrac(int64(row), int64(total))63+ }64+ cols, err := rows.Columns(excelize.Options{RawCellValue: true})65+ if err != nil {66+ return nil, err67+ }68+ for col, raw := range cols {69+ a := sheet.Addr{Col: col, Row: row}70+ if !b.fits(a) {71+ break72+ }73+ cell, _ := excelize.CoordinatesToCellName(col+1, row+1)74+ importXLSXCell(x, b, ws, cell, a, raw, styles)75+ }76+ }77+ if err := rows.Error(); err != nil {78+ return nil, err79+ }80+ prog.setRows(row)81+82+ for c := range sheet.MaxCols {83+ colName, _ := excelize.ColumnNumberToName(c + 1)84+ w, err := x.GetColWidth(ws, colName)85+ if err != nil || math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 {86+ continue87+ }88+ b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))89+ }90+ s, notes := b.finish(notes)91+ return &Result{Sheet: s, Rows: row, Notes: notes}, nil92+}93+94+// xlsxStyle is what 012 keeps of an Excel cell style.95+type xlsxStyle struct {96+ format sheet.Format97+ style sheet.Style98+}99+100+func styleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {101+ if st, ok := cache[id]; ok {102+ return st103+ }104+ var out xlsxStyle105+ if st, err := x.GetStyle(id); err == nil && st != nil {106+ code := ""107+ if st.CustomNumFmt != nil {108+ code = *st.CustomNumFmt109+ }110+ out.format = formatOf(st.NumFmt, code)111+ if f := st.Font; f != nil {112+ out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike113+ out.style.Underline = f.Underline != "" && f.Underline != "none"114+ }115+ if al := st.Alignment; al != nil {116+ switch al.Horizontal {117+ case "left":118+ out.style.Align = sheet.AlignLeft119+ case "center", "centerContinuous":120+ out.style.Align = sheet.AlignCenter121+ case "right":122+ out.style.Align = sheet.AlignRight123+ }124+ }125+ }126+ cache[id] = out127+ return out128+}129+130+func importXLSXCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {131+ var st xlsxStyle132+ if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {133+ st = styleOf(x, id, styles)134+ }135+ typ, _ := x.GetCellType(ws, cell)136+ keep := func() {137+ switch typ {138+ case excelize.CellTypeBool:139+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)140+ return141+ case excelize.CellTypeSharedString, excelize.CellTypeInlineString, excelize.CellTypeFormula, excelize.CellTypeError:142+ b.text(a, raw, st.format, st.style)143+ return144+ }145+ if v, err := strconv.ParseFloat(raw, 64); err == nil {146+ b.number(a, fromExcelSerial(v, st.format), st.format, st.style)147+ return148+ }149+ b.text(a, raw, st.format, st.style)150+ }151+ if f, err := x.GetCellFormula(ws, cell); err == nil && f != "" {152+ b.formula(a, fromExcelFormula(f), st.format, st.style, keep)153+ return154+ }155+ if raw == "" {156+ b.put(a, "", st.format, st.style)157+ return158+ }159+ keep()160+}161+162+// exportXLSX writes a workbook with one sheet: values, formulas in163+// Excel's syntax with their results cached, number formats, text styles,164+// alignment and column widths.165+func exportXLSX(name string, snap *Snapshot) (*ExportResult, error) {166+ x := excelize.NewFile()167+ defer x.Close()168+ ws := sheetName(snap.Name)169+ if err := x.SetSheetName("Sheet1", ws); err != nil {170+ return nil, err171+ }172+ sw, err := x.NewStreamWriter(ws)173+ if err != nil {174+ return nil, err175+ }176+ for c, w := range snap.Widths {177+ if err := sw.SetColWidth(c+1, c+1, float64(max(w-excelPadding, 1))); err != nil {178+ return nil, err179+ }180+ }181+ styleIDs := map[xlsxStyle]int{}182+ styleFor := func(f sheet.Format, st sheet.Style) (int, error) {183+ key := xlsxStyle{f, st}184+ if id, ok := styleIDs[key]; ok {185+ return id, nil186+ }187+ xs := &excelize.Style{}188+ if code := excelCode(f); code != "" {189+ xs.CustomNumFmt = &code190+ }191+ if st.Bold || st.Italic || st.Underline || st.Strikethrough {192+ xs.Font = &excelize.Font{Bold: st.Bold, Italic: st.Italic, Strike: st.Strikethrough}193+ if st.Underline {194+ xs.Font.Underline = "single"195+ }196+ }197+ if st.Align != sheet.AlignAuto {198+ xs.Alignment = &excelize.Alignment{Horizontal: st.Align.String()}199+ }200+ id, err := x.NewStyle(xs)201+ styleIDs[key] = id202+ return id, err203+ }204+205+ r := snap.Range206+ values := 0207+ example := ""208+ for row := r.From.Row; row <= r.To.Row; row++ {209+ var line []any210+ for col := r.From.Col; col <= r.To.Col; col++ {211+ a := sheet.Addr{Col: col, Row: row}212+ c, ok := snap.Cells[a]213+ if !ok {214+ line = append(line, nil)215+ continue216+ }217+ // A formula's inferred format is written too, so its result218+ // shows the same in Excel.219+ f := c.Format220+ id := 0221+ if !f.IsZero() || !c.Style.IsZero() {222+ if id, err = styleFor(f, c.Style); err != nil {223+ return nil, err224+ }225+ }226+ xc := excelize.Cell{StyleID: id, Value: xlsxValue(c.Value, f)}227+ if c.Formula {228+ if fx, ok := toExcelFormula(c.Input); ok {229+ xc.Formula = fx230+ } else {231+ values++232+ if example == "" {233+ example = a.String()234+ }235+ }236+ }237+ line = append(line, xc)238+ }239+ cell, _ := excelize.CoordinatesToCellName(r.From.Col+1, row+1)240+ if err := sw.SetRow(cell, line); err != nil {241+ return nil, err242+ }243+ }244+ if err := sw.Flush(); err != nil {245+ return nil, err246+ }247+ yes := true248+ if err := x.SetCalcProps(&excelize.CalcPropsOptions{FullCalcOnLoad: &yes}); err != nil {249+ return nil, err250+ }251+ buf, err := x.WriteToBuffer()252+ if err != nil {253+ return nil, err254+ }255+ res := &ExportResult{Rows: r.To.Row - r.From.Row + 1}256+ if values > 0 {257+ res.Notes = append(res.Notes, fmt.Sprintf("%s with no Excel equivalent saved as values, e.g. %s",258+ count(values, "formula", "formulas"), example))259+ }260+ return res, writeFile(name, buf.Bytes())261+}262+263+// xlsxValue is a computed value as excelize writes it.264+func xlsxValue(v sheet.Value, f sheet.Format) any {265+ switch v.Kind {266+ case sheet.Number:267+ return toExcelSerial(v.Num, f)268+ case sheet.Bool:269+ return v.Num != 0270+ case sheet.Text, sheet.Error:271+ return v.Str272+ }273+ return nil274+}275+276+// sheetName makes a valid Excel sheet name: at most 31 characters, none277+// of : \ / ? * [ ].278+func sheetName(s string) string {279+ s = strings.Map(func(r rune) rune {280+ if strings.ContainsRune(`:\/?*[]`, r) {281+ return '_'282+ }283+ return r284+ }, s)285+ s = strings.Trim(strings.TrimSpace(s), "'")286+ if r := []rune(s); len(r) > 31 {287+ s = string(r[:31])288+ }289+ if s == "" {290+ return "Sheet1"291+ }292+ return s293+}
A internal/fileio/xlsx_test.go +238 -0
@@ -0,0 +1,238 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "012/internal/sheet"12+)13+14+func TestXLSXRoundTrip(t *testing.T) {15+ src := build(t, map[string]string{16+ "A1": "Bills", "B1": "Due", "C1": "Amount", "D1": "Share",17+ "A2": "Rent", "B2": "10/1/2026", "C2": "$1,450.00", "D2": "=C2/C$4",18+ "A3": "Food", "B3": "2026-09-28", "C3": "612.4", "D3": "=@ROUND(C3/C$4..C$4, 2)",19+ "A4": "Total", "C4": "=SUM(C2:C3)", "D4": "=IFNA(XLOOKUP(\"Rent\",A2:A3,C2:C3),0)",20+ "A5": "'123", "B5": "14:30", "C5": "TRUE", "D5": "1.5E+30",21+ "A6": "old", "B6": "2/1/1900", "C6": "=C2>C3 #AND# C3>0", "E6": "=A1&\" x\"",22+ })23+ r := func(s string) sheet.Rect { return sheet.NewRect(addr(t, s), addr(t, s)) }24+ src.SetFormat(r("D2"), sheet.Preset(sheet.FmtPercent))25+ src.SetFormat(r("C3"), sheet.Format{Kind: sheet.FmtAccounting, Decimals: 1})26+ src.SetFormat(r("B5"), sheet.Format{Kind: sheet.FmtTime, Pattern: "h:mm AM/PM"})27+ src.SetFormat(r("A6"), sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.000"})28+ src.SetStyle(r("A1"), func(st *sheet.Style) { st.Bold, st.Italic = true, true })29+ src.SetStyle(r("A2"), func(st *sheet.Style) { st.Underline, st.Strikethrough = true, true })30+ src.SetStyle(r("B1"), func(st *sheet.Style) { st.Align = sheet.AlignRight })31+ src.SetColWidth(0, 14)32+ src.SetColWidth(3, 7)33+34+ name := filepath.Join(t.TempDir(), "bills.xlsx")35+ res, err := Export(context.Background(), name, XLSX, Snap(src, sheet.Rect{}, "Bills"), ExportOptions{})36+ if err != nil {37+ t.Fatal(err)38+ }39+ if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "1 formula with no Excel equivalent saved as values, e.g. C6") {40+ t.Errorf("notes %q", res.Notes)41+ }42+43+ // Excel sees Excel formulas, with _xlfn. for newer functions.44+ x, err := excelize.OpenFile(name)45+ if err != nil {46+ t.Fatal(err)47+ }48+ for cell, want := range map[string]string{49+ "D3": "ROUND(C3/C$4:C$4, 2)", "D4": `_xlfn.IFNA(_xlfn.XLOOKUP("Rent",A2:A3,C2:C3),0)`, "C6": "",50+ } {51+ if got, _ := x.GetCellFormula("Bills", cell); got != want {52+ t.Errorf("Excel formula in %s = %q, want %q", cell, got, want)53+ }54+ }55+ if v, _ := x.GetCellValue("Bills", "C4"); v != "$2,062.40" {56+ t.Errorf("cached C4 = %q", v)57+ }58+ x.Close()59+60+ got, err := Import(context.Background(), name, Options{})61+ if err != nil {62+ t.Fatal(err)63+ }64+ s := got.Sheet65+ for _, a := range src.Addrs() {66+ if want, g := shown(src, a), shown(s, a); g != want {67+ t.Errorf("%s shows %q after the round trip, want %q", a, g, want)68+ }69+ if want, g := src.Cell(a).Style, s.Cell(a).Style; g != want {70+ t.Errorf("%s style %+v, want %+v", a, g, want)71+ }72+ }73+ for cell, want := range map[string]string{74+ "D2": "=C2/C$4", "D3": "=ROUND(C3/C$4:C$4, 2)", "D4": `=IFNA(XLOOKUP("Rent",A2:A3,C2:C3),0)`,75+ "A5": "'123", "C5": "TRUE", "C6": "TRUE", "E6": `=A1&" x"`,76+ } {77+ if g := input(s, addr(t, cell)); g != want {78+ t.Errorf("%s input = %q, want %q", cell, g, want)79+ }80+ }81+ for cell, want := range map[string]sheet.Format{82+ "B2": {Kind: sheet.FmtDate}, "C3": {Kind: sheet.FmtAccounting, Decimals: 1},83+ "B5": {Kind: sheet.FmtTime, Pattern: "h:mm AM/PM"}, "A6": {Kind: sheet.FmtCustom, Pattern: "0.000"},84+ } {85+ if g := s.Cell(addr(t, cell)).Format; g != want {86+ t.Errorf("%s format %+v, want %+v", cell, g, want)87+ }88+ }89+ if w := s.ColWidth(0); w != 14 {90+ t.Errorf("A width = %d", w)91+ }92+ if w := s.ColWidth(3); w != 7 {93+ t.Errorf("D width = %d", w)94+ }95+ if w := s.ColWidth(1); w != sheet.DefaultWidth {96+ t.Errorf("B width = %d", w)97+ }98+}99+100+// TestXLSXImport reads a workbook written by excelize the way Excel101+// writes one: several sheets, shared strings, built-in formats and102+// formulas 012 can't read.103+func TestXLSXImport(t *testing.T) {104+ x := excelize.NewFile()105+ x.SetSheetName("Sheet1", "Q1")106+ x.NewSheet("Q2")107+ x.NewSheet("Q3")108+ x.SetCellValue("Q1", "A1", "Region")109+ x.SetCellValue("Q1", "B1", 1234.5)110+ x.SetCellValue("Q1", "C1", "=not a formula")111+ x.SetCellValue("Q1", "D1", 45000)112+ x.SetCellValue("Q1", "E1", true)113+ x.SetCellFormula("Q1", "A2", "Q2!A1*2")114+ x.SetCellValue("Q1", "A2", 84) // the cached value115+ x.SetCellFormula("Q1", "A2", "Q2!A1*2")116+ x.SetCellFormula("Q1", "B2", "SUM(B1,1)")117+ x.SetCellValue("Q2", "A1", 42)118+ id, _ := x.NewStyle(&excelize.Style{NumFmt: 4, Font: &excelize.Font{Bold: true}})119+ x.SetCellStyle("Q1", "B1", "B1", id)120+ date, _ := x.NewStyle(&excelize.Style{NumFmt: 15})121+ x.SetCellStyle("Q1", "D1", "D1", date)122+ pct, _ := x.NewStyle(&excelize.Style{NumFmt: 10, Alignment: &excelize.Alignment{Horizontal: "center"}})123+ x.SetCellStyle("Q1", "B2", "B2", pct)124+ name := filepath.Join(t.TempDir(), "q.xlsx")125+ if err := x.SaveAs(name); err != nil {126+ t.Fatal(err)127+ }128+129+ prog := NewProgress()130+ res, err := Import(context.Background(), name, Options{Progress: prog})131+ if err != nil {132+ t.Fatal(err)133+ }134+ s := res.Sheet135+ for cell, want := range map[string]string{136+ "A1": "Region", "B1": "1,234.50", "C1": "=not a formula", "D1": "15-Mar-23", "E1": "TRUE",137+ "A2": "84", "B2": "123550.00%",138+ } {139+ if g := shown(s, addr(t, cell)); g != want {140+ t.Errorf("%s shows %q, want %q", cell, g, want)141+ }142+ }143+ if g := input(s, addr(t, "B2")); g != "=SUM(B1,1)" {144+ t.Errorf("B2 input %q", g)145+ }146+ if st := s.Cell(addr(t, "B1")).Style; !st.Bold {147+ t.Errorf("B1 not bold")148+ }149+ if st := s.Cell(addr(t, "B2")).Style; st.Align != sheet.AlignCenter {150+ t.Errorf("B2 align %v", st.Align)151+ }152+ notes := strings.Join(res.Notes, "; ")153+ for _, want := range []string{154+ "imported the first sheet, Q1; 2 other sheets not imported (Q2, Q3)",155+ "1 formula kept as values, e.g. A2 =Q2!A1*2",156+ } {157+ if !strings.Contains(notes, want) {158+ t.Errorf("notes %q, want %q", notes, want)159+ }160+ }161+ if rows, frac := prog.Get(); rows != 2 {162+ t.Errorf("progress %d rows, %v", rows, frac)163+ }164+}165+166+func TestFormatCodes(t *testing.T) {167+ for _, tc := range []struct {168+ id int169+ code string170+ want sheet.Format171+ }{172+ {0, "", sheet.Format{}},173+ {0, "General", sheet.Format{}},174+ {3, "", sheet.Format{Kind: sheet.FmtNumber}},175+ {4, "", sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}},176+ {7, "", sheet.Format{Kind: sheet.FmtCurrency, Decimals: 2}},177+ {9, "", sheet.Format{Kind: sheet.FmtPercent}},178+ {11, "", sheet.Format{Kind: sheet.FmtScientific, Decimals: 2}},179+ {14, "", sheet.Format{Kind: sheet.FmtDate}},180+ {22, "", sheet.Format{Kind: sheet.FmtDateTime, Pattern: "m/d/yyyy h:mm"}},181+ {44, "", sheet.Format{Kind: sheet.FmtAccounting, Decimals: 2}},182+ {46, "", sheet.Format{Kind: sheet.FmtDuration}},183+ {49, "", sheet.Format{Kind: sheet.FmtText}},184+ {164, `\$#,##0.00`, sheet.Format{Kind: sheet.FmtCurrency, Decimals: 2}},185+ {164, `[$$-409]#,##0`, sheet.Format{Kind: sheet.FmtCurrency}},186+ {164, "yyyy-mm-dd", sheet.Format{Kind: sheet.FmtDate, Pattern: "yyyy-mm-dd"}},187+ {164, "hh:mm", sheet.Format{Kind: sheet.FmtTime, Pattern: "hh:mm"}},188+ {164, "0.0", sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.0"}},189+ {164, "[Red]0.00", sheet.Format{Kind: sheet.FmtCustom, Pattern: "[Red]0.00"}},190+ } {191+ if got := formatOf(tc.id, tc.code); got != tc.want {192+ t.Errorf("formatOf(%d, %q) = %+v, want %+v", tc.id, tc.code, got, tc.want)193+ }194+ }195+ // Every format 012 writes reads back as itself.196+ for k := sheet.FmtText; k <= sheet.FmtDuration; k++ {197+ for d := range 4 {198+ f := sheet.Format{Kind: k}199+ if sheet.Preset(k).Decimals > 0 {200+ f.Decimals = d201+ }202+ if got := formatOf(164, excelCode(f)); got != f {203+ t.Errorf("%+v -> %q -> %+v", f, excelCode(f), got)204+ }205+ }206+ }207+}208+209+func TestExcelFormulas(t *testing.T) {210+ for _, tc := range []struct {211+ in, want string212+ ok bool213+ }{214+ {"=SUM(A1:B2)", "SUM(A1:B2)", true},215+ {"=@SUM(A1..B2)", "SUM(A1:B2)", true},216+ {`=IF(A1="@x..y",1,2)`, `IF(A1="@x..y",1,2)`, true},217+ {"=@PI*2", "PI()*2", true},218+ {"=$A$1*1.5E3", "$A$1*1.5E3", true},219+ {"=xlookup(1,A:A,B:B)", "_xlfn.XLOOKUP(1,A:A,B:B)", true},220+ {`=JEV.TEST(A2,"Is it?")`, "", false},221+ {"=A1>1 #AND# B1<2", "", false},222+ {"=#REF!+1", "#REF!+1", true},223+ } {224+ got, ok := toExcelFormula(tc.in)225+ if ok != tc.ok || ok && got != tc.want {226+ t.Errorf("toExcelFormula(%q) = %q, %v, want %q, %v", tc.in, got, ok, tc.want, tc.ok)227+ }228+ }229+ for in, want := range map[string]string{230+ "SUM(A1:B2)": "=SUM(A1:B2)",231+ "_xlfn.IFNA(_xlfn.XLOOKUP(1,A:A,B:B),0)": "=IFNA(XLOOKUP(1,A:A,B:B),0)",232+ `"_xlfn.kept"&A1`: `="_xlfn.kept"&A1`,233+ } {234+ if got := fromExcelFormula(in); got != want {235+ t.Errorf("fromExcelFormula(%q) = %q, want %q", in, got, want)236+ }237+ }238+}