feat(oo): native conversion, deep links, sheet export (re-land) #75
@@ -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=<DocumentServer CoAuthoring secret>
|
||||
# docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath
|
||||
|
||||
|
||||
+123
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user