oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "fmt"
5 "os/exec"
6 "path/filepath"
7 "regexp"
8 "strings"
9)
10
11// poisonedCacheValue is written into every formula cell's office:value
12// cache before LibreOffice ever sees the file. No case in the list can
13// produce it, so if convert-to exported the cached value instead of
14// recalculating, every row would come back as this number and
15// checkResults would refuse the run rather than committing 60 identical
16// wrong answers. (It does not: LibreOffice 26.8.0.3 recalculates on
17// convert-to, proven by this exact poisoning.)
18const poisonedCacheValue = "999999"
19
20// representableErrors are the error tokens calc's own ErrKind can name
21// (errors.go, via corpus_test.go's corpusErrByName) and that classify
22// therefore records as an "err" value. Any OTHER #-prefixed token --
23// #NULL!, #SPILL!, #CALC!, #GETTING_DATA -- would fall through
24// classify's switch and be written into the corpus as an ordinary
25// STRING whose text happens to start with "#", which no reader would
26// notice and which calc could never match. checkResults refuses one
27// instead, so widening calc's error set stays a deliberate act.
28var representableErrors = map[string]bool{
29 "#DIV/0!": true, "#VALUE!": true, "#REF!": true,
30 "#NAME?": true, "#NUM!": true, "#N/A": true,
31}
32
33// libreOfficeErrPattern matches LibreOffice's own internal formula
34// errors ("Err:510" for a formula it could not parse, and friends).
35// These are NOT Excel-style error values — they mean the document, not
36// the formula, is wrong, and classify would otherwise silently record
37// them as ordinary string results.
38var libreOfficeErrPattern = regexp.MustCompile(`^Err:\d+$`)
39
40// userProfileDir is where soffice is told to keep its user installation.
41// Passing -env:UserInstallation is required, not optional: without it
42// soffice uses (and may need to create) the invoking user's real
43// profile, and a first run there can drop into an interactive setup flow
44// that never returns in a headless context. A throwaway directory under
45// this run's temp dir keeps the run hermetic and repeatable.
46func userProfileDir(tmpDir string) string {
47 return "file://" + filepath.Join(tmpDir, "profile")
48}
49
50// sofficeVersion runs `soffice --version`, both to confirm the binary is
51// usable before any work is done and to capture the exact build string
52// that is recorded in the generated corpus's note — the corpus says
53// which LibreOffice produced it because it was asked, not because a
54// human typed a version number in by hand.
55func sofficeVersion(soffice, tmpDir string) (string, error) {
56 cmd := exec.Command(soffice, "--headless", "--norestore",
57 "-env:UserInstallation="+userProfileDir(tmpDir), "--version")
58 out, err := cmd.CombinedOutput()
59 if err != nil {
60 return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
61 }
62 // The output is "LibreOffice 26.8.0.3 <git hash>"; the hash is noise
63 // for a provenance note, the version is the point.
64 fields := strings.Fields(string(out))
65 if len(fields) >= 2 && fields[0] == "LibreOffice" {
66 return "LibreOffice " + fields[1], nil
67 }
68 return strings.TrimSpace(string(out)), nil
69}
70
71// runSoffice converts the .ods to CSV and returns the path of the CSV
72// soffice wrote. A document soffice cannot open at all exits non-zero
73// with "source file could not be loaded" on stdout (measured against
74// 26.8.0.3, by feeding it a deliberately malformed package), so the
75// exit status is checked and its combined output is included in the
76// error — the failure that actually happens here is a bad .ods, and the
77// message soffice prints is the only clue to which part of it is bad.
78func runSoffice(soffice, tmpDir, odsPath string) (string, error) {
79 cmd := exec.Command(soffice, "--headless", "--norestore",
80 "-env:UserInstallation="+userProfileDir(tmpDir),
81 "--convert-to", "csv", "--outdir", tmpDir, odsPath)
82 output, err := cmd.CombinedOutput()
83 if err != nil {
84 return "", fmt.Errorf("soffice failed: %w\n%s", err, output)
85 }
86 csvPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(odsPath), ".ods")+".csv")
87 return csvPath, nil
88}
89
90// checkResults refuses a run whose output cannot be trusted, rather than
91// letting a broken pipeline write a plausible-looking corpus. Each of
92// these has been an actual observed failure mode of this tool, not a
93// hypothetical:
94//
95// - a row count that does not match the case list (rows lost, or the
96// support table read as a case);
97// - the poisoned cache value coming back, meaning convert-to exported
98// the stale office:value instead of recalculating;
99// - "Err:NNN", LibreOffice's own parse/document error, which classify
100// would otherwise record as a perfectly ordinary string;
101// - an empty cell, which is what an unrecalculated or unparsed formula
102// looks like when it has no cached value at all;
103// - an error token calc's ErrKind cannot name (see
104// representableErrors), which classify would write into the corpus
105// as an ordinary string;
106// - "#NAME?", which in this tool always means LibreOffice does not
107// recognise the function under the ODF spelling gencorpus emitted
108// (add it to ods.go's odfFunctionNames), never a real answer any
109// case is asking for. This is a statement about spelling, not about
110// semantics: a case that genuinely expects #NAME? would go in
111// producesUnknownName, which is empty.
112func checkResults(cases []corpusCase, results []string) error {
113 if len(results) != len(cases) {
114 return fmt.Errorf("soffice returned %d rows, expected %d — did headless convert-to actually recalculate?", len(results), len(cases))
115 }
116 for i, r := range results {
117 switch {
118 case r == poisonedCacheValue:
119 return fmt.Errorf("row %d (%s): got the poisoned cache value %s — soffice exported the stale cached result instead of recalculating", i+1, cases[i].Expr, poisonedCacheValue)
120 case libreOfficeErrPattern.MatchString(r):
121 return fmt.Errorf("row %d (%s): LibreOffice reported %s — the generated formula is not valid ODF, fix toODFFormula (ods.go), do not record this as a value", i+1, cases[i].Expr, r)
122 case r == "" && !producesEmpty(cases[i].Expr):
123 return fmt.Errorf("row %d (%s): empty result — the cell was not recalculated, or the formula was not recognised", i+1, cases[i].Expr)
124 case r == "#NAME?" && !producesUnknownName(cases[i].Expr):
125 return fmt.Errorf("row %d (%s): LibreOffice returned #NAME? — it does not know this function under the ODF name gencorpus emitted (%s); add it to odfFunctionNames in ods.go rather than recording #NAME? as the expected answer", i+1, cases[i].Expr, toODFFormula(cases[i].Expr))
126 case strings.HasPrefix(r, "#") && !representableErrors[r]:
127 return fmt.Errorf("row %d (%s): LibreOffice returned %s, an error calc's own ErrKind cannot name — it would be written into the corpus as a plain string, not an error; add the kind to calc/errors.go first, then to classify", i+1, cases[i].Expr, r)
128 }
129 }
130 return nil
131}
132
133// producesEmpty names the expressions whose correct answer really is an
134// empty cell, so checkResults' empty-cell guard cannot be widened by
135// accident into "empties are fine". Empty today: no case in the list
136// legitimately produces one.
137func producesEmpty(expr string) bool { return false }
138
139// producesUnknownName names the expressions whose correct answer really
140// is #NAME?. Empty today: every case in the list names a function
141// LibreOffice knows, once ods.go's odfFunctionNames has spelled it the
142// way ODF does.
143func producesUnknownName(expr string) bool { return false }
144