feat(oo): native conversion, deep links, sheet export (re-land) #75

Merged
eSlider merged 9 commits from feat/oo-conversion-links into main 2026-09-22 22:42:07 +01:00
5 changed files with 310 additions and 1 deletions
Showing only changes of commit 5e73f6c335 - Show all commits
+9
View File
@@ -549,6 +549,9 @@ type Task struct {
| `SignJWT(secret, payload)` | HS256 JWT, stdlib only | | `SignJWT(secret, payload)` | HS256 JWT, stdlib only |
| `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) | | `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) |
| `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer | | `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: 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 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 ./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) 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> # export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret>
# docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath # docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath
+123
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "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(docsConvertCmd())
cmd.AddCommand(docsPDFCmd()) cmd.AddCommand(docsPDFCmd())
cmd.AddCommand(docsPresignedCmd()) cmd.AddCommand(docsPresignedCmd())
cmd.AddCommand(docsCSVCmd())
cmd.AddCommand(docsJSONCmd())
cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOptimizeCmd())
cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsOCRCmd())
cmd.AddCommand(docsHOCRCmd()) 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, // docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL,
// else the portal's /ds-vpath proxy. // else the portal's /ds-vpath proxy.
func docsBaseURL(flag string) string { func docsBaseURL(flag string) string {
+1 -1
View File
@@ -16,7 +16,7 @@
// oo crm cleanup // oo crm cleanup
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete // 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 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 catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
+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
}
+54
View File
@@ -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")
}
}