feat(oo): sheet-aware spreadsheet export (docs csv/json)

- lib: WorkbookSheetNames/CSV/JSON (excelize) — sheet selection + JSON rows
- oo docs csv  SRC... [--sheet N] [--delimiter ,|;|||tab] [--out]
- oo docs json SRC... [--sheet N] [--out]
- SRC = OO file id or local path; --sheet is 1-based (default first)
- DS csv output is first-sheet-only (verified) -> local reader for selection/JSON
- test: TestWorkbookSheetExport
This commit is contained in:
2026-09-22 22:41:14 +01:00
parent 7c089a6439
commit 5e73f6c335
5 changed files with 310 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
package onlyoffice
// Spreadsheet (XLS/XLSX/ODS) sheet-aware export to CSV / JSON.
//
// The OnlyOffice DocumentServer converter can emit CSV, but only for the first
// worksheet and it ignores any sheet selector (verified against a live DS).
// So sheet selection and JSON use a local reader (excelize). This is data
// extraction, not a document-format conversion pipeline.
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"strings"
"github.com/xuri/excelize/v2"
)
// WorkbookSheetNames returns worksheet names in workbook order.
func WorkbookSheetNames(data []byte) ([]string, error) {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("open workbook: %w", err)
}
defer f.Close()
return f.GetSheetList(), nil
}
// workbookSheet resolves a 0-based sheet index to its name. A negative index
// selects the first sheet; an out-of-range index is an error.
func workbookSheet(f *excelize.File, index int) (string, error) {
names := f.GetSheetList()
if len(names) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if index < 0 {
index = 0
}
if index >= len(names) {
return "", fmt.Errorf("sheet index %d out of range (0..%d)", index, len(names)-1)
}
return names[index], nil
}
// WorkbookSheetCSV renders one worksheet as CSV. sheetIndex is 0-based
// (negative = first). delimiter 0 keeps the default comma. header, when true,
// is kept as the first CSV row (it is always kept; the flag only affects JSON).
func WorkbookSheetCSV(data []byte, sheetIndex int, delimiter rune) (string, error) {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("open workbook: %w", err)
}
defer f.Close()
name, err := workbookSheet(f, sheetIndex)
if err != nil {
return "", err
}
rows, err := f.GetRows(name)
if err != nil {
return "", err
}
var b strings.Builder
w := csv.NewWriter(&b)
if delimiter != 0 {
w.Comma = delimiter
}
for _, row := range rows {
_ = w.Write(row)
}
w.Flush()
return b.String(), w.Error()
}
// WorkbookSheetJSON renders one worksheet as a list of row objects, using the
// first row as the field names (blank headers become col1, col2, …). sheetIndex
// is 0-based (negative = first).
func WorkbookSheetJSON(data []byte, sheetIndex int) ([]map[string]any, error) {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("open workbook: %w", err)
}
defer f.Close()
name, err := workbookSheet(f, sheetIndex)
if err != nil {
return nil, err
}
rows, err := f.GetRows(name)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
header := rows[0]
out := make([]map[string]any, 0, len(rows)-1)
for _, row := range rows[1:] {
m := make(map[string]any, len(header))
for i, h := range header {
key := strings.TrimSpace(h)
if key == "" {
key = fmt.Sprintf("col%d", i+1)
}
if i < len(row) {
m[key] = row[i]
} else {
m[key] = ""
}
}
out = append(out, m)
}
return out, nil
}
// WorkbookSheetCSVTo is WorkbookSheetCSV writing into w.
func WorkbookSheetCSVTo(data []byte, sheetIndex int, delimiter rune, w io.Writer) error {
s, err := WorkbookSheetCSV(data, sheetIndex, delimiter)
if err != nil {
return err
}
_, err = io.WriteString(w, s)
return err
}