oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1// Package xlsx converts between an XLSX workbook and one tab of this
2// app's own storage (internal/sheets/store). excelize is confined to
3// this package deliberately: its calc engine works against a file model
4// rather than our dependency graph (internal/sheets/calc), so letting any
5// other package import it would couple our storage to theirs.
6package xlsx
7
8import (
9 "errors"
10 "fmt"
11 "io"
12
13 "github.com/xuri/excelize/v2"
14
15 "amadan.net/oficina/sheets/internal/sheets/store"
16)
17
18// importActor is the actor id Import's own writes are attributed with.
19// Import's signature (fixed by the interface this package implements)
20// carries no caller identity, and the background-job wrapper that would
21// thread a real uploading user's id through (spec §11: "Import runs as a
22// rastrillo background job") is plan 2, not built in this repo yet. 0 is
23// not a real user id anywhere in this schema, so it reads unambiguously
24// as "the system, not a person" in the event log.
25const importActor uint64 = 0
26
27// openLimits bounds what a workbook may decompress to.
28//
29// XLSX is a ZIP, so the size of the upload bounds nothing: excelize's
30// own defaults are UnzipSizeLimit 16GB and an unzipXML limit derived
31// from it, and the caller's byte cap on the HTTP body is a cap on
32// COMPRESSED bytes. A small file of highly compressible XML therefore
33// reaches OpenReader and GetRows — both of which materialise what they
34// read — long before Import's own MaxCellsPerTab check gets a chance to
35// refuse it, because that check counts cells that have already been
36// decompressed into memory.
37//
38// UnzipSizeLimit is the hard bound and the one doing the work here:
39// 256MB unzipped is generous for a workbook that has to fit in
40// MaxCellsPerTab (200,000 cells) and small enough that several
41// concurrent imports cannot exhaust an instance.
42//
43// UnzipXMLSizeLimit is NOT a second rejection threshold, whatever its
44// name suggests — excelize uses it to decide when a worksheet or shared
45// string table is large enough to stream through temporary storage
46// instead of memory (its default is StreamChunkSize). Setting it lower
47// than the total therefore bounds resident memory rather than refusing
48// anything, which is worth having and is not the protection.
49//
50// This is a bound, not validation: a legitimate workbook past it is
51// refused the same as a malicious one, and that is the right trade for
52// a limit whose job is to stop the process dying.
53var openLimits = excelize.Options{
54 UnzipSizeLimit: 256 << 20,
55 UnzipXMLSizeLimit: 64 << 20,
56}
57
58// Import reads an XLSX workbook's first worksheet into h, cell by cell:
59// literal values become literal input, formulas are parsed and
60// evaluated by this app's own engine (never excelize's — see the package
61// doc), and each cell's fill, number format, bold, italic and alignment
62// are carried over via SetFormat.
63//
64// Errors carry coordinates and codes, never cell content (spec §2.4):
65// import runs as a background job whose error text reaches the owner and
66// platform logs this app cannot grep, so "cannot parse cell B7" is what
67// gets said — never what B7 actually contained.
68func Import(r io.Reader, h *store.Handle) error {
69 f, err := excelize.OpenReader(r, openLimits)
70 if err != nil {
71 return fmt.Errorf("xlsx: open workbook: %w", err)
72 }
73 defer f.Close()
74
75 sheetList := f.GetSheetList()
76 if len(sheetList) == 0 {
77 return errors.New("xlsx: workbook has no worksheets")
78 }
79 sheetName := sheetList[0]
80
81 rows, err := f.GetRows(sheetName, excelize.Options{RawCellValue: true})
82 if err != nil {
83 return fmt.Errorf("xlsx: read worksheet: %w", err)
84 }
85
86 // Fail before writing anything: a partial import left behind by
87 // hitting the cap mid-way through would be a confusing half state,
88 // and the bounding box is a cheap, conservative upper bound on the
89 // cell count to check it against up front.
90 total := 0
91 for _, row := range rows {
92 total += len(row)
93 }
94 if total > store.MaxCellsPerTab {
95 return store.ErrTabTooLarge
96 }
97
98 for rowPos, row := range rows {
99 for colPos, raw := range row {
100 if err := importCell(f, sheetName, h, rowPos, colPos, raw); err != nil {
101 return err
102 }
103 }
104 }
105
106 // Layout last, and best effort. A workbook whose widths cannot be
107 // read is still a workbook whose cells can, and refusing the import
108 // over its margins would trade the document for its formatting.
109 widest := 0
110 for _, row := range rows {
111 if len(row) > widest {
112 widest = len(row)
113 }
114 }
115 importSizes(f, sheetName, h, len(rows), widest)
116 return nil
117}
118
119// importCell imports one XLSX cell at (rowPos, colPos) (0-based). It
120// creates the backing Row/Col only if the cell actually has content or a
121// format worth recording — an interior blank cell with neither leaves no
122// trace, matching the sparse model (models.go's comment on Cell).
123func importCell(f *excelize.File, sheetName string, h *store.Handle, rowPos, colPos int, raw string) error {
124 cellRef, err := excelize.CoordinatesToCellName(colPos+1, rowPos+1)
125 if err != nil {
126 return cellErr("cannot address cell", rowPos, colPos, err)
127 }
128
129 input, err := cellInputText(f, sheetName, cellRef, rowPos, colPos, raw)
130 if err != nil {
131 return err
132 }
133
134 styleID, err := f.GetCellStyle(sheetName, cellRef)
135 if err != nil {
136 return cellErr("cannot read the style at", rowPos, colPos, err)
137 }
138 style, err := f.GetStyle(styleID)
139 if err != nil {
140 return cellErr("cannot read the style at", rowPos, colPos, err)
141 }
142 format, hasFormat := formatFromStyle(style)
143
144 if input == "" && !hasFormat {
145 return nil // nothing here worth a Row/Col at all
146 }
147
148 rowID, err := h.EnsureRow(rowPos)
149 if err != nil {
150 return cellErr("cannot make the row for cell", rowPos, colPos, err)
151 }
152 colID, err := h.EnsureCol(colPos)
153 if err != nil {
154 return cellErr("cannot make the column for cell", rowPos, colPos, err)
155 }
156
157 if input != "" {
158 if err := h.Set(rowID, colID, input, importActor); err != nil {
159 return cellErr("cannot parse cell", rowPos, colPos, err)
160 }
161 }
162 if hasFormat {
163 if err := h.SetFormat(rowID, colID, format); err != nil {
164 return cellErr("cannot set the format at", rowPos, colPos, err)
165 }
166 }
167 return nil
168}
169
170// cellInputText decides what raw text (this app's own Set contract:
171// "" is blank, a leading "=" is a formula, anything else is a literal —
172// store/handle_codec.go) a cell becomes.
173//
174// A genuine XLSX formula is detected through excelize's own formula
175// reader, never by sniffing raw's leading character — but a plain TEXT
176// cell whose content happens to start with "=" is indistinguishable from
177// a formula once it becomes this app's raw text, because Set's contract
178// (predating this task — store/handle_codec.go, task 10) has exactly one
179// signal for "is this a formula": a leading "=" in the one text field a
180// cell has. That is a pre-existing modeling gap this task inherits, not
181// one introduced by XLSX import; it is called out in the task report
182// rather than worked around here.
183func cellInputText(f *excelize.File, sheetName, cellRef string, rowPos, colPos int, raw string) (string, error) {
184 formula, err := f.GetCellFormula(sheetName, cellRef)
185 if err != nil {
186 return "", cellErr("cannot read the formula at", rowPos, colPos, err)
187 }
188 if formula != "" {
189 return "=" + formula, nil
190 }
191 if raw == "" {
192 return "", nil
193 }
194
195 cellType, err := f.GetCellType(sheetName, cellRef)
196 if err != nil {
197 return "", cellErr("cannot read the cell type at", rowPos, colPos, err)
198 }
199 if cellType == excelize.CellTypeBool {
200 if raw == "1" {
201 return "TRUE", nil
202 }
203 return "FALSE", nil
204 }
205 return raw, nil
206}
207