diff --git a/README.md b/README.md index b078e32..5e78088 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,9 @@ type Task struct { | `SignJWT(secret, payload)` | HS256 JWT, stdlib only | | `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) | | `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer | +| `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook | +| `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) | +| `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) | Example — convert a portal file (or a local file) to PDF with the native engine: @@ -621,6 +624,12 @@ oo docs presigned 3684 # short-lived fetchable URL of an OO fi oo docs pdf 3684 --out out.pdf # OO file → PDF (via DocumentServer) oo docs pdf ./report.docx --to pdf # local file → PDF on the fly (temp upload+cleanup) oo docs pdf 3684 --stream > out.pdf # pipe: bytes to stdout (alias --pipe) + +# Spreadsheet export (sheet-aware; local reader — the DS csv output is first-sheet-only) +oo docs csv 227 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based) +oo docs csv 227 --delimiter ';' # ; | | \t via --delimiter +oo docs json 227 --sheet 2 # worksheet → JSON rows (first row = header) +oo docs csv ./book.xlsx --sheet 1 --out sheet1.csv # export ONLYOFFICE_DS_SECRET= # docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 6484738..1f206c5 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -37,6 +39,8 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, cmd.AddCommand(docsConvertCmd()) cmd.AddCommand(docsPDFCmd()) cmd.AddCommand(docsPresignedCmd()) + cmd.AddCommand(docsCSVCmd()) + cmd.AddCommand(docsJSONCmd()) cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsHOCRCmd()) @@ -210,6 +214,125 @@ func docsPresignedCmd() *cobra.Command { } } +// loadWorkbookBytes reads an argument that is either an OnlyOffice file id +// (downloaded via the client) or a local path. +func loadWorkbookBytes(cmd *cobra.Command, c *onlyoffice.Client, arg string) ([]byte, error) { + if fi, err := os.Stat(arg); err == nil && !fi.IsDir() { + return os.ReadFile(arg) + } + var buf bytes.Buffer + if _, err := c.DownloadFile(cmd.Context(), arg, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// parseDelimiter maps a flag value to a CSV delimiter rune ("," default). +func parseDelimiter(s string) rune { + switch s { + case "", ",": + return ',' + case "\\t", "tab", "\t": + return '\t' + case ";": + return ';' + case "|": + return '|' + default: + r := []rune(s) + return r[0] + } +} + +func docsCSVCmd() *cobra.Command { + var sheet int + var delim, out string + cmd := &cobra.Command{ + Use: "csv SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to CSV (sheet-aware)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). + +Why not the DocumentServer: its csv output covers only the first worksheet and +ignores a sheet selector (verified). Sheet selection and JSON use a local reader.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + d := parseDelimiter(delim) + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + text, err := onlyoffice.WorkbookSheetCSV(data, sheet-1, d) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + if out != "" { + if err := os.WriteFile(out, []byte(text), 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "output": out, "bytes": len(text)}) + continue + } + fmt.Print(text) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&delim, "delimiter", ",", "CSV delimiter (',', ';', '|', 'tab')") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + +func docsJSONCmd() *cobra.Command { + var sheet int + var out string + cmd := &cobra.Command{ + Use: "json SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to JSON rows (first row = header)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). +Each data row becomes an object keyed by the header cells of that sheet.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + rows, err := onlyoffice.WorkbookSheetJSON(data, sheet-1) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if out != "" { + if err := os.WriteFile(out, b, 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "rows": len(rows), "output": out}) + continue + } + os.Stdout.Write(b) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + // docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL, // else the portal's /ds-vpath proxy. func docsBaseURL(flag string) string { diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 00b6de1..7e41451 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -16,7 +16,7 @@ // oo crm cleanup // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … -// oo docs tools | convert | pdf | presigned | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx +// oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx // oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird // oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] diff --git a/sheets.go b/sheets.go new file mode 100644 index 0000000..9bd617f --- /dev/null +++ b/sheets.go @@ -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 +} diff --git a/sheets_test.go b/sheets_test.go new file mode 100644 index 0000000..feee720 --- /dev/null +++ b/sheets_test.go @@ -0,0 +1,54 @@ +package onlyoffice + +import ( + "bytes" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestWorkbookSheetExport(t *testing.T) { + f := excelize.NewFile() + f.SetSheetName("Sheet1", "first") + _ = f.SetCellValue("first", "A1", "name") + _ = f.SetCellValue("first", "B1", "age") + _ = f.SetCellValue("first", "A2", "Alice") + _ = f.SetCellValue("first", "B2", 30) + _, _ = f.NewSheet("second") + _ = f.SetCellValue("second", "A1", "city") + _ = f.SetCellValue("second", "A2", "Lisbon") + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatal(err) + } + data := buf.Bytes() + + names, err := WorkbookSheetNames(data) + if err != nil || len(names) != 2 { + t.Fatalf("names=%v err=%v", names, err) + } + + csv0, err := WorkbookSheetCSV(data, 0, 0) + if err != nil || !strings.Contains(csv0, "Alice") { + t.Fatalf("sheet0 csv=%q err=%v", csv0, err) + } + csv1, err := WorkbookSheetCSV(data, 1, 0) + if err != nil || !strings.Contains(csv1, "Lisbon") || strings.Contains(csv1, "Alice") { + t.Fatalf("sheet1 csv=%q err=%v", csv1, err) + } + // negative index = first sheet + csvDefault, _ := WorkbookSheetCSV(data, -1, 0) + if !strings.Contains(csvDefault, "Alice") { + t.Fatalf("default csv=%q", csvDefault) + } + + js, err := WorkbookSheetJSON(data, 1) + if err != nil || len(js) != 1 || js[0]["city"] != "Lisbon" { + t.Fatalf("sheet1 json=%#v err=%v", js, err) + } + + if _, err := WorkbookSheetCSV(data, 5, 0); err == nil { + t.Fatal("expected out-of-range error") + } +}