feat(oo): native conversion, deep links, sheet export (re-land) #75
+154
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
||||||
@@ -32,6 +34,8 @@ OCR a scan locally: oo docs ocr scan.pdf --md out.md
|
|||||||
Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
|
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(docsPresignedCmd())
|
||||||
cmd.AddCommand(docsOptimizeCmd())
|
cmd.AddCommand(docsOptimizeCmd())
|
||||||
cmd.AddCommand(docsOCRCmd())
|
cmd.AddCommand(docsOCRCmd())
|
||||||
cmd.AddCommand(docsHOCRCmd())
|
cmd.AddCommand(docsHOCRCmd())
|
||||||
@@ -61,6 +65,156 @@ func docsToolsCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func docsPDFCmd() *cobra.Command {
|
||||||
|
var out, docsURL, secret, output, folder string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pdf FILE_ID | PATH [ARG...]",
|
||||||
|
Short: "Convert files to PDF via the DocumentServer converter (OO file ids or local paths)",
|
||||||
|
Long: `Native OnlyOffice conversion (the engine behind the portal's "Download as PDF"):
|
||||||
|
|
||||||
|
1. GET /api/2.0/files/file/{id}/presigneduri → fetchable source URL
|
||||||
|
2. POST <docs>/converter with a JWT → converted file URL
|
||||||
|
3. download the result
|
||||||
|
|
||||||
|
Arguments may be OnlyOffice file ids OR local file paths. A local path is
|
||||||
|
uploaded to the scratch folder (--folder, default 2 = "My documents"), converted,
|
||||||
|
downloaded and then removed — so any local document yields a PDF on the fly.
|
||||||
|
|
||||||
|
Docs base defaults to $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + "/ds-vpath"
|
||||||
|
(the portal nginx proxies /ds-vpath to the DocumentServer). The JWT secret is
|
||||||
|
$ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base := docsBaseURL(docsURL)
|
||||||
|
if base == "" {
|
||||||
|
return fmt.Errorf("docs base url unknown; set --docs-url or ONLYOFFICE_DOCS_URL")
|
||||||
|
}
|
||||||
|
sec := secret
|
||||||
|
if sec == "" {
|
||||||
|
sec = firstEnv("ONLYOFFICE_DS_SECRET", "OO_DS_SECRET")
|
||||||
|
}
|
||||||
|
if sec == "" {
|
||||||
|
return fmt.Errorf("JWT secret required: --secret or ONLYOFFICE_DS_SECRET")
|
||||||
|
}
|
||||||
|
ot := output
|
||||||
|
if ot == "" {
|
||||||
|
ot = "pdf"
|
||||||
|
}
|
||||||
|
for _, arg := range args {
|
||||||
|
id, local := arg, false
|
||||||
|
title := ""
|
||||||
|
if fi, statErr := os.Stat(arg); statErr == nil && !fi.IsDir() {
|
||||||
|
// Local file → temporary upload into the scratch folder.
|
||||||
|
ent, uerr := c.UploadToFolder(cmd.Context(), folder, arg)
|
||||||
|
if uerr != nil {
|
||||||
|
return fmt.Errorf("upload %s: %w", arg, uerr)
|
||||||
|
}
|
||||||
|
id = strconv.FormatInt(onlyoffice.FileEntryNumericID(ent), 10)
|
||||||
|
local = true
|
||||||
|
title = filepath.Base(arg)
|
||||||
|
} else {
|
||||||
|
if f, ferr := c.GetFile(cmd.Context(), id); ferr == nil && f != nil && f.Title != nil {
|
||||||
|
title = *f.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, err := c.PresignedURI(cmd.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("presigneduri %s: %w", id, err)
|
||||||
|
}
|
||||||
|
res, err := c.ConvertDocument(cmd.Context(), base, sec, onlyoffice.ConvertRequest{
|
||||||
|
URL: src,
|
||||||
|
OutputType: ot,
|
||||||
|
FileType: strings.TrimPrefix(filepath.Ext(title), "."),
|
||||||
|
Title: title,
|
||||||
|
Key: fmt.Sprintf("oo-%s-%d", id, time.Now().UnixNano()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("convert %s: %w", id, err)
|
||||||
|
}
|
||||||
|
dst := out
|
||||||
|
if dst == "" {
|
||||||
|
stem := strings.TrimSuffix(title, filepath.Ext(title))
|
||||||
|
if stem == "" {
|
||||||
|
stem = "file-" + id
|
||||||
|
}
|
||||||
|
dst = stem + "." + ot
|
||||||
|
}
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, f)
|
||||||
|
f.Close()
|
||||||
|
if local {
|
||||||
|
// Best-effort cleanup of the temporary upload.
|
||||||
|
if nid, e := strconv.Atoi(id); e == nil {
|
||||||
|
_ = c.DeleteFiles(cmd.Context(), []int{nid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if derr != nil {
|
||||||
|
return fmt.Errorf("download: %w", derr)
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"source": arg, "fileid": id, "title": title, "output": dst, "bytes": n, "type": ot})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&out, "out", "", "output path (default: ./<title>.<format>)")
|
||||||
|
cmd.Flags().StringVar(&output, "to", "pdf", "output format (pdf, docx, xlsx, …)")
|
||||||
|
cmd.Flags().StringVar(&docsURL, "docs-url", "", "DocumentServer base (default $ONLYOFFICE_DOCS_URL or $ONLYOFFICE_URL/ds-vpath)")
|
||||||
|
cmd.Flags().StringVar(&secret, "secret", "", "JWT secret (default $ONLYOFFICE_DS_SECRET)")
|
||||||
|
cmd.Flags().StringVar(&folder, "folder", "2", "scratch folder id for local-file uploads")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsPresignedCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "presigned FILE_ID",
|
||||||
|
Short: "Print a short-lived fetchable URI for a portal file",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, err := c.PresignedURI(cmd.Context(), args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"fileid": args[0], "uri": u})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL,
|
||||||
|
// else the portal's /ds-vpath proxy.
|
||||||
|
func docsBaseURL(flag string) string {
|
||||||
|
if flag != "" {
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
if v := os.Getenv("ONLYOFFICE_DOCS_URL"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := firstEnv("ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"); v != "" {
|
||||||
|
return strings.TrimRight(v, "/") + "/ds-vpath"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstEnv(keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func strOrNil(s string) any {
|
func strOrNil(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+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 | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
// oo docs tools | convert | pdf | presigned | 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]
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Document conversion via the OnlyOffice DocumentServer converter.
|
||||||
|
//
|
||||||
|
// The DocumentServer (the same engine behind the portal's "Download as PDF")
|
||||||
|
// converts any office format. From a portal-reachable host the converter is
|
||||||
|
// exposed at "<portal>/ds-vpath/converter" (nginx proxy) or directly at
|
||||||
|
// "http://<docs-server>:8083/converter" (legacy path: /ConvertService.ashx).
|
||||||
|
//
|
||||||
|
// Flow: PresignedURI(fileId) → Convert(docsBase, secret, req) → download
|
||||||
|
// result.FileURL. The JWT is HS256 signed with the DocumentServer's
|
||||||
|
// services.CoAuthoring.secret (NOT storage.fs.secretString).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PresignedURI returns a short-lived, fetchable download URI for a portal file
|
||||||
|
// (GET /api/2.0/files/file/{fileId}/presigneduri). The DocumentServer can fetch
|
||||||
|
// it without the caller's session, so it is the input for Convert.
|
||||||
|
func (c *Client) PresignedURI(ctx context.Context, fileID string) (string, error) {
|
||||||
|
if fileID == "" {
|
||||||
|
return "", fmt.Errorf("file id is required")
|
||||||
|
}
|
||||||
|
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/files/file/%s/presigneduri", url.PathEscape(fileID)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := responseField(raw, "response")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(resp, &s); err == nil && s != "" {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
// Some builds return an object instead of a bare string.
|
||||||
|
var o map[string]any
|
||||||
|
if err := json.Unmarshal(resp, &o); err == nil {
|
||||||
|
for _, k := range []string{"uri", "url", "Uri", "Url"} {
|
||||||
|
if v, ok := o[k].(string); ok && v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("presigneduri: unexpected response %s", truncate(string(resp), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertRequest is the DocumentServer converter body.
|
||||||
|
type ConvertRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
OutputType string `json:"outputtype"`
|
||||||
|
FileType string `json:"filetype,omitempty"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertResult is the DocumentServer converter reply.
|
||||||
|
type ConvertResult struct {
|
||||||
|
FileURL string `json:"fileUrl"`
|
||||||
|
FileType string `json:"fileType"`
|
||||||
|
Percent int `json:"percent"`
|
||||||
|
EndConvert bool `json:"endConvert"`
|
||||||
|
Error *int `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignJWT builds an HS256 JWT with the given payload (stdlib only).
|
||||||
|
func SignJWT(secret string, payload any) (string, error) {
|
||||||
|
if secret == "" {
|
||||||
|
return "", fmt.Errorf("jwt secret is empty")
|
||||||
|
}
|
||||||
|
hb, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pb, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
enc := base64.RawURLEncoding.EncodeToString
|
||||||
|
signing := enc(hb) + "." + enc(pb)
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(signing))
|
||||||
|
return signing + "." + enc(mac.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertDocument asks a DocumentServer to convert req.URL into req.OutputType.
|
||||||
|
// docsBase is e.g. "https://portal/ds-vpath" or "http://localhost:8083";
|
||||||
|
// secret is the DocumentServer CoAuthoring JWT secret. Passes the JWT both as
|
||||||
|
// the AuthorizationJwt header and as a body token.
|
||||||
|
func (c *Client) ConvertDocument(ctx context.Context, docsBase, secret string, req ConvertRequest) (*ConvertResult, error) {
|
||||||
|
if strings.TrimSpace(docsBase) == "" {
|
||||||
|
return nil, fmt.Errorf("docs base url is required")
|
||||||
|
}
|
||||||
|
if req.URL == "" {
|
||||||
|
return nil, fmt.Errorf("source url is required")
|
||||||
|
}
|
||||||
|
if req.OutputType == "" {
|
||||||
|
return nil, fmt.Errorf("outputtype is required")
|
||||||
|
}
|
||||||
|
if req.Key == "" {
|
||||||
|
return nil, fmt.Errorf("conversion key is required")
|
||||||
|
}
|
||||||
|
jwt, err := SignJWT(secret, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimRight(docsBase, "/") + "/converter"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
httpReq.Header.Set("AuthorizationJwt", "Bearer "+jwt)
|
||||||
|
resp, err := c.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converter request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("converter: %d %s", resp.StatusCode, truncate(string(raw), 300))
|
||||||
|
}
|
||||||
|
var out ConvertResult
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("converter decode: %w (%s)", err, truncate(string(raw), 200))
|
||||||
|
}
|
||||||
|
if out.Error != nil {
|
||||||
|
return &out, fmt.Errorf("converter error %d", *out.Error)
|
||||||
|
}
|
||||||
|
if out.FileURL == "" {
|
||||||
|
return &out, fmt.Errorf("converter returned no fileUrl")
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadURLTo streams an absolute URL (no portal auth) into dst.
|
||||||
|
func (c *Client) DownloadURLTo(ctx context.Context, rawurl string, dst io.Writer) (int64, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawurl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return 0, fmt.Errorf("download: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.Copy(dst, resp.Body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignJWT(t *testing.T) {
|
||||||
|
payload := map[string]any{"url": "u", "outputtype": "pdf"}
|
||||||
|
tok, err := SignJWT("secret", payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(strings.Split(tok, ".")); n != 3 {
|
||||||
|
t.Fatalf("JWT must have 3 parts, got %d", n)
|
||||||
|
}
|
||||||
|
tok2, _ := SignJWT("secret", payload)
|
||||||
|
if tok != tok2 {
|
||||||
|
t.Fatal("SignJWT must be deterministic for identical input")
|
||||||
|
}
|
||||||
|
if _, err := SignJWT("", payload); err == nil {
|
||||||
|
t.Fatal("expected error for empty secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user