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 |
|
| `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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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