feat(oo): native conversion, deep links, sheet export (re-land) #75
@@ -476,6 +476,11 @@ type Task struct {
|
|||||||
| `UpdateProject(req)` | Update project details |
|
| `UpdateProject(req)` | Update project details |
|
||||||
| `DeleteProject(id)` | Delete a project |
|
| `DeleteProject(id)` | Delete a project |
|
||||||
| `GetProjectMilestones(project)` | Get milestones with task counts |
|
| `GetProjectMilestones(project)` | Get milestones with task counts |
|
||||||
|
| `DeleteMilestone(id)` | Remove a milestone |
|
||||||
|
| `ListProjectTeam(ctx, id)` | Portal users on the project team |
|
||||||
|
| `AddProjectTeamUser(ctx, id, userID)` | Add a portal user to the team |
|
||||||
|
| `RemoveProjectTeamUser(ctx, id, userID)` | Remove a portal user from the team |
|
||||||
|
| `SetProjectTeam(ctx, id, participants, notify)` | Replace the team with the given user ids |
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
@@ -502,6 +507,14 @@ type Task struct {
|
|||||||
| Method | Description |
|
| Method | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GetUsers()` | List all users with profiles |
|
| `GetUsers()` | List all users with profiles |
|
||||||
|
| `GetUser(ctx, id)` | One user profile by id |
|
||||||
|
| `CreateUser(ctx, NewUserRequest)` | Add a portal user |
|
||||||
|
| `UpdateUser(ctx, id, body)` | Update profile fields (JSON PUT) |
|
||||||
|
| `DeleteUser(ctx, id)` | Delete permanently (auto-terminates first — OO refuses active users) |
|
||||||
|
| `BlockUser(ctx, id)` / `UnblockUser(ctx, id)` | Terminate / reactivate (login kept/denied) |
|
||||||
|
| `ChangeUserPassword(ctx, id, pw)` | Set a new password |
|
||||||
|
| `ChangeUserStatus(ctx, id, active)` | Activate / Terminate via `people/status` |
|
||||||
|
| `AuthenticateAs(ctx, login, pw)` | Verify a login without mutating the cached token |
|
||||||
|
|
||||||
### Documents Files
|
### Documents Files
|
||||||
|
|
||||||
@@ -526,6 +539,28 @@ type Task struct {
|
|||||||
| `DefaultRetryPolicy()` | From env: 7 attempts, 2s base, 2m cap (`OO_RETRY_ATTEMPTS/_BASE/_MAX`) |
|
| `DefaultRetryPolicy()` | From env: 7 attempts, 2s base, 2m cap (`OO_RETRY_ATTEMPTS/_BASE/_MAX`) |
|
||||||
| `Transient(err)` | True for retriable OnlyOffice answers (`*TransientError` or HTTP 429/502/503/504 text) |
|
| `Transient(err)` | True for retriable OnlyOffice answers (`*TransientError` or HTTP 429/502/503/504 text) |
|
||||||
|
|
||||||
|
### Deep links & conversion
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| `FileEditorURL(portalBase, id)` / `(c *Client).FileEditorURL(id)` | DocEditor deep link `/Products/Files/DocEditor.aspx?fileid=` |
|
||||||
|
| `FolderURL(portalBase, id)` / `(c *Client).FolderURL(id)` | Documents folder link |
|
||||||
|
| `PresignedURI(ctx, fileID)` | Short-lived fetchable URL of a portal file (`presigneduri`) |
|
||||||
|
| `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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret>
|
||||||
|
oo docs pdf 1234 --out out.pdf # OO file id → PDF
|
||||||
|
oo docs pdf ./report.docx --to pdf # local file → PDF (temp upload, auto-cleanup)
|
||||||
|
```
|
||||||
|
|
||||||
### Helper Types
|
### Helper Types
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
@@ -582,6 +617,41 @@ oo opportunities list
|
|||||||
oo opportunities stages
|
oo opportunities stages
|
||||||
oo cases list
|
oo cases list
|
||||||
oo crm-tasks categories
|
oo crm-tasks categories
|
||||||
|
|
||||||
|
# Deep links & native document conversion
|
||||||
|
oo link 1234 2345 # DocEditor URL for file ids (title + url)
|
||||||
|
oo docs presigned 1234 # short-lived fetchable URL of an OO file
|
||||||
|
oo docs pdf 1234 --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 1234 --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 1234 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based)
|
||||||
|
oo docs csv 1234 --delimiter ';' # ; | | \t via --delimiter
|
||||||
|
oo docs json 1234 --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
|
||||||
|
|
||||||
|
# Project team (portal users) CRUD
|
||||||
|
oo projects team list 42
|
||||||
|
oo projects team add 42 <user_id> [<user_id>...]
|
||||||
|
oo projects team remove 42 <user_id>
|
||||||
|
oo projects team set 42 <user_id> [...] # replace team (may lag; verify with list)
|
||||||
|
oo projects milestone-delete 7
|
||||||
|
|
||||||
|
# Project documents: fresh re-upload (single clean version) / new version
|
||||||
|
oo projects files replace-in 1 ./contract.pdf # hard delete same stem|ext in folder + upload
|
||||||
|
oo projects files update 1 ./contract.docx # overwrite content, same file id
|
||||||
|
|
||||||
|
# Users lifecycle
|
||||||
|
oo users list ; oo users get <user_id>
|
||||||
|
oo users create --first Jane --last Doe --email jane.doe@example.com --password '…'
|
||||||
|
oo users check --login jane.doe@example.com # verify login (email works when userName 500s)
|
||||||
|
oo users update <user_id> --title "…" --location "…"
|
||||||
|
oo users block <user_id> ; oo users unblock <user_id>
|
||||||
|
oo users password <user_id> # reads the new password from stdin
|
||||||
|
oo users delete <user_id> [<user_id>...]
|
||||||
```
|
```
|
||||||
|
|
||||||
### office (TUI)
|
### office (TUI)
|
||||||
@@ -765,7 +835,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV]
|
|||||||
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
||||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
||||||
| `docs` | `tools`, `convert`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
| `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
||||||
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
||||||
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
||||||
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
||||||
@@ -777,6 +847,12 @@ CLI-only concern — the library itself never loads dotfiles).
|
|||||||
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
|
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
|
||||||
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
||||||
|
|
||||||
|
Catalog scanning (`oo catalog scan-projects` / `scan-thunderbird`) classifies
|
||||||
|
clients from rules in `$OO_CATALOG_CONFIG` (or `--config`); see
|
||||||
|
[`catalog/classify.example.yaml`](catalog/classify.example.yaml). Without rules
|
||||||
|
nothing is classified as work. The MinIO download fallback is off unless
|
||||||
|
`MINIO_ENDPOINT` + `MINIO_ACCESS_KEY` + `MINIO_SECRET_KEY` are set.
|
||||||
|
|
||||||
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
||||||
|
|
||||||
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||||
|
|||||||
@@ -98,6 +98,50 @@ func (c *Client) authenticateOnce(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthenticateAs verifies a login/password pair against the portal WITHOUT
|
||||||
|
// mutating the client's cached token. It returns nil when the portal issues a
|
||||||
|
// token, and the portal error otherwise.
|
||||||
|
//
|
||||||
|
// OnlyOffice accepts either the userName or the account email as the login. On
|
||||||
|
// some portals the account userName login returns HTTP 500 "User authentication
|
||||||
|
// failed" while the account email succeeds — confirmed for a freshly created
|
||||||
|
// guest user. Use this probe before sharing credentials (see `oo users check`),
|
||||||
|
// and prefer the email as the login.
|
||||||
|
func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error {
|
||||||
|
body, err := json.Marshal(Credentials{User: login, Password: password})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("auth request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response *Token `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return fmt.Errorf("auth decode: %w", err)
|
||||||
|
}
|
||||||
|
if env.Response == nil || env.Response.Value == "" {
|
||||||
|
return fmt.Errorf("auth: empty token in response")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateToken clears the cached authentication token. The next request
|
// InvalidateToken clears the cached authentication token. The next request
|
||||||
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
||||||
//
|
//
|
||||||
|
|||||||
+296
@@ -1,11 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"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 +37,10 @@ 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(docsCSVCmd())
|
||||||
|
cmd.AddCommand(docsJSONCmd())
|
||||||
cmd.AddCommand(docsOptimizeCmd())
|
cmd.AddCommand(docsOptimizeCmd())
|
||||||
cmd.AddCommand(docsOCRCmd())
|
cmd.AddCommand(docsOCRCmd())
|
||||||
cmd.AddCommand(docsHOCRCmd())
|
cmd.AddCommand(docsHOCRCmd())
|
||||||
@@ -61,6 +70,293 @@ func docsToolsCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func docsPDFCmd() *cobra.Command {
|
||||||
|
var out, docsURL, secret, output, folder string
|
||||||
|
var stream, pipe bool
|
||||||
|
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"
|
||||||
|
(/ds-vpath is the usual reverse-proxy mount for 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"
|
||||||
|
}
|
||||||
|
toStdout := stream || pipe
|
||||||
|
if toStdout && len(args) > 1 {
|
||||||
|
return fmt.Errorf("--stream/--pipe writes one file to stdout; pass a single input")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
var w io.Writer
|
||||||
|
dst := out
|
||||||
|
if toStdout {
|
||||||
|
w = os.Stdout
|
||||||
|
} else {
|
||||||
|
if dst == "" {
|
||||||
|
stem := strings.TrimSuffix(title, filepath.Ext(title))
|
||||||
|
if stem == "" {
|
||||||
|
stem = "file-" + id
|
||||||
|
}
|
||||||
|
dst = stem + "." + ot
|
||||||
|
}
|
||||||
|
fh, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w = fh
|
||||||
|
defer fh.Close()
|
||||||
|
}
|
||||||
|
n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, w)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if toStdout {
|
||||||
|
// Keep stdout byte-clean for pipes; status goes to stderr.
|
||||||
|
fmt.Fprintf(os.Stderr, "converted %s -> stdout (%d bytes, %s)\n", arg, n, ot)
|
||||||
|
} else {
|
||||||
|
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")
|
||||||
|
cmd.Flags().BoolVar(&stream, "stream", false, "write the converted bytes to stdout (pipe-friendly)")
|
||||||
|
cmd.Flags().BoolVar(&pipe, "pipe", false, "alias for --stream")
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 standard /ds-vpath reverse-proxy mount.
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(linkCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkCmd prints deep links for file ids. Used to build third-party document
|
||||||
|
// packs whose cover embeds links to contracts and supporting files. File ids
|
||||||
|
// come from `oo projects files list` / `oo dav ls`; `oo projects files
|
||||||
|
// replace-in` keeps them clean when a document is re-uploaded.
|
||||||
|
func linkCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "link FILE_ID [FILE_ID...]",
|
||||||
|
Short: "Print OnlyOffice DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range args {
|
||||||
|
printObject(map[string]any{
|
||||||
|
"fileid": id,
|
||||||
|
"title": fileTitle(cmd, c, id),
|
||||||
|
"url": c.FileEditorURL(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileTitle best-effort resolves a file title; never fails the command.
|
||||||
|
func fileTitle(cmd *cobra.Command, c *onlyoffice.Client, id string) string {
|
||||||
|
f, err := c.GetFile(cmd.Context(), id)
|
||||||
|
if err != nil || f == nil || f.Title == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *f.Title
|
||||||
|
}
|
||||||
+4
-3
@@ -3,9 +3,10 @@
|
|||||||
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
||||||
//
|
//
|
||||||
// oo calendar list | events | add | delete
|
// oo calendar list | events | add | delete
|
||||||
// oo projects list | get | milestones | milestone-create | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx)
|
// oo projects list | get | milestones | milestone-create | milestone-delete | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx)
|
||||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||||
// oo users list | self | get | create | update | delete | block | unblock | password (alias: oo whoami)
|
// oo users list | self | get | create | update | delete | block | unblock | password | check (alias: oo whoami)
|
||||||
|
// oo link FILE_ID [FILE_ID...] DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)
|
||||||
// oo contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove
|
// oo contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove
|
||||||
// oo persons list | create | delete | dedupe
|
// oo persons list | create | delete | dedupe
|
||||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||||
@@ -15,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 | 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]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func init() {
|
|||||||
projectsCmd.AddCommand(prjGetCmd())
|
projectsCmd.AddCommand(prjGetCmd())
|
||||||
projectsCmd.AddCommand(prjMilestonesCmd())
|
projectsCmd.AddCommand(prjMilestonesCmd())
|
||||||
projectsCmd.AddCommand(prjMilestoneCreateCmd())
|
projectsCmd.AddCommand(prjMilestoneCreateCmd())
|
||||||
|
projectsCmd.AddCommand(prjMilestoneDeleteCmd())
|
||||||
projectsCmd.AddCommand(prjCreateCmd())
|
projectsCmd.AddCommand(prjCreateCmd())
|
||||||
projectsCmd.AddCommand(prjUpdateCmd())
|
projectsCmd.AddCommand(prjUpdateCmd())
|
||||||
projectsCmd.AddCommand(prjDeleteCmd())
|
projectsCmd.AddCommand(prjDeleteCmd())
|
||||||
@@ -297,6 +298,32 @@ func prjMilestoneCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prjMilestoneDeleteCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "milestone-delete MILESTONE_ID [MILESTONE_ID...]",
|
||||||
|
Aliases: []string{"milestone-rm"},
|
||||||
|
Short: "Delete project milestone(s) by id",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, raw := range args {
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("milestone id %q must be integer: %w", raw, err)
|
||||||
|
}
|
||||||
|
if err := c.DeleteMilestone(id); err != nil {
|
||||||
|
return fmt.Errorf("delete milestone %d: %w", id, err)
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"milestone_id": id, "deleted": true})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func prjCreateCmd() *cobra.Command {
|
func prjCreateCmd() *cobra.Command {
|
||||||
var desc, resp string
|
var desc, resp string
|
||||||
var country, company string
|
var country, company string
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func projectFilesCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.AddCommand(prjFilesListCmd())
|
cmd.AddCommand(prjFilesListCmd())
|
||||||
cmd.AddCommand(prjFilesUploadCmd())
|
cmd.AddCommand(prjFilesUploadCmd())
|
||||||
|
cmd.AddCommand(prjFilesReplaceInCmd())
|
||||||
|
cmd.AddCommand(prjFilesUpdateCmd())
|
||||||
cmd.AddCommand(prjFilesDownloadCmd())
|
cmd.AddCommand(prjFilesDownloadCmd())
|
||||||
cmd.AddCommand(prjFilesRenameCmd())
|
cmd.AddCommand(prjFilesRenameCmd())
|
||||||
cmd.AddCommand(prjFilesDeleteCmd())
|
cmd.AddCommand(prjFilesDeleteCmd())
|
||||||
@@ -149,6 +151,68 @@ Pass --no-replace to fail when the name is taken; --allow-duplicate to always cr
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prjFilesReplaceInCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "replace-in FOLDER_ID LOCAL_PATH [LOCAL_PATH...]",
|
||||||
|
Short: "Replace same-named file(s) in a folder: hard delete + fresh upload (no version history)",
|
||||||
|
Long: `Deletes any file in FOLDER_ID with the same stem|ext (hard delete — the CLI
|
||||||
|
delete is permanent) and uploads the local file fresh. Unlike 'update' this
|
||||||
|
leaves a single clean version.
|
||||||
|
|
||||||
|
Why it exists: repeated 'update' of a shared document accumulated a visible
|
||||||
|
version history and left a stale id. replace-in yields one clean revision; then
|
||||||
|
point links at the returned id (or keep an nginx alias for the legacy fileid).
|
||||||
|
|
||||||
|
Note: file ids are server-assigned; a fresh upload gets a new id.`,
|
||||||
|
Args: cobra.MinimumNArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
folderID := args[0]
|
||||||
|
for _, p := range args[1:] {
|
||||||
|
stem := onlyoffice.UploadStemFromLocal(p)
|
||||||
|
ext := onlyoffice.UploadExtFromLocal(p)
|
||||||
|
deleted, derr := c.DeleteFilesByDedupKey(cmd.Context(), folderID, stem, ext)
|
||||||
|
if derr != nil {
|
||||||
|
return derr
|
||||||
|
}
|
||||||
|
ent, uerr := c.UploadToFolder(cmd.Context(), folderID, p)
|
||||||
|
if uerr != nil {
|
||||||
|
return uerr
|
||||||
|
}
|
||||||
|
obj := fileEntryToMap(ent)
|
||||||
|
if len(deleted) > 0 {
|
||||||
|
obj["replaced_file_ids"] = deleted
|
||||||
|
}
|
||||||
|
printObject(obj)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prjFilesUpdateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "update FILE_ID LOCAL_PATH",
|
||||||
|
Short: "Overwrite an existing Documents file with new content (new version)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry, err := c.UpdateFile(cmd.Context(), args[0], args[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printObject(fileEntryToMap(entry))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func prjFilesDownloadCmd() *cobra.Command {
|
func prjFilesDownloadCmd() *cobra.Command {
|
||||||
var to string
|
var to string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func init() {
|
|||||||
usersCmd.AddCommand(usersBlockCmd())
|
usersCmd.AddCommand(usersBlockCmd())
|
||||||
usersCmd.AddCommand(usersUnblockCmd())
|
usersCmd.AddCommand(usersUnblockCmd())
|
||||||
usersCmd.AddCommand(usersPasswordCmd())
|
usersCmd.AddCommand(usersPasswordCmd())
|
||||||
|
usersCmd.AddCommand(usersCheckCmd())
|
||||||
rootCmd.AddCommand(whoamiCmd())
|
rootCmd.AddCommand(whoamiCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +313,54 @@ func usersPasswordCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func usersCheckCmd() *cobra.Command {
|
||||||
|
var login, password string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "check",
|
||||||
|
Short: "Check that a login can authenticate (userName or email)",
|
||||||
|
Long: `Probes POST /api/2.0/authentication.json with the given credentials and
|
||||||
|
discards the token.
|
||||||
|
|
||||||
|
Where this is used: before handing portal credentials to an external party
|
||||||
|
(e.g. a guest given read access to a document pack), verify the login actually
|
||||||
|
works. On some portals the account email is the reliable login identifier — the
|
||||||
|
userName login fails with 500 for a freshly created user — so share the email,
|
||||||
|
not the userName.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if login == "" {
|
||||||
|
return fmt.Errorf("--login is required (userName or email)")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
if b, err := readLine(os.Stdin); err == nil {
|
||||||
|
password = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.AuthenticateAs(cmd.Context(), login, password); err != nil {
|
||||||
|
printObject(map[string]any{"login": login, "ok": false, "error": trimAuthErr(err)})
|
||||||
|
return fmt.Errorf("login failed for %s", login)
|
||||||
|
}
|
||||||
|
printObject(map[string]any{"login": login, "ok": true})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&login, "login", "", "userName or email")
|
||||||
|
cmd.Flags().StringVar(&password, "password", "", "password (omit to read one line from stdin)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimAuthErr keeps the error short for table output.
|
||||||
|
func trimAuthErr(err error) string {
|
||||||
|
s := err.Error()
|
||||||
|
if len(s) > 160 {
|
||||||
|
s = s[:160] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// readLine reads a single trimmed line from r.
|
// readLine reads a single trimmed line from r.
|
||||||
func readLine(r *os.File) (string, error) {
|
func readLine(r *os.File) (string, error) {
|
||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
|
|||||||
+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" (reverse 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Deep links to OnlyOffice portal objects.
|
||||||
|
//
|
||||||
|
// Used by tools that embed per-file links of the form
|
||||||
|
// /Products/Files/DocEditor.aspx?fileid=<id> in a cover document or a chat
|
||||||
|
// message. Those links must be consistent so they match the file ids returned
|
||||||
|
// by `oo projects files list` / `oo link`.
|
||||||
|
//
|
||||||
|
// Caveat: file ids are server-assigned and a re-upload/delete yields a NEW id,
|
||||||
|
// so an already-shared link can go stale. Use `oo projects files replace-in`
|
||||||
|
// (keeps the id stable) and, if a legacy link must keep working, an nginx alias
|
||||||
|
// can 302 the old fileid to the new one.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileEditorURL builds the OnlyOffice DocEditor deep link for a portal file id:
|
||||||
|
//
|
||||||
|
// https://<portal>/Products/Files/DocEditor.aspx?fileid=<id>
|
||||||
|
//
|
||||||
|
// portalBase may include a trailing slash; fileID is trimmed and URL-escaped.
|
||||||
|
func FileEditorURL(portalBase, fileID string) string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(portalBase), "/")
|
||||||
|
return fmt.Sprintf("%s/Products/Files/DocEditor.aspx?fileid=%s",
|
||||||
|
base, url.QueryEscape(strings.TrimSpace(fileID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEditorURL is the client-bound convenience wrapper (uses the portal URL
|
||||||
|
// the client was built with).
|
||||||
|
func (c *Client) FileEditorURL(fileID string) string {
|
||||||
|
return FileEditorURL(c.baseURL(), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderURL builds a Documents-folder deep link for a folder id.
|
||||||
|
func FolderURL(portalBase, folderID string) string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(portalBase), "/")
|
||||||
|
return fmt.Sprintf("%s/Products/Files/Default.aspx#folder=%s", base, url.QueryEscape(strings.TrimSpace(folderID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderURL is the client-bound convenience wrapper.
|
||||||
|
func (c *Client) FolderURL(folderID string) string {
|
||||||
|
return FolderURL(c.baseURL(), folderID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFileEditorURL(t *testing.T) {
|
||||||
|
cases := []struct{ base, id, want string }{
|
||||||
|
{"https://office.example.com", "3651", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3651"},
|
||||||
|
{"https://office.example.com/", " 1234 ", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=1234"},
|
||||||
|
{"http://localhost:8087", "a/b", "http://localhost:8087/Products/Files/DocEditor.aspx?fileid=a%2Fb"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := FileEditorURL(c.base, c.id); got != c.want {
|
||||||
|
t.Fatalf("FileEditorURL(%q,%q) = %q, want %q", c.base, c.id, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderURL(t *testing.T) {
|
||||||
|
got := FolderURL("https://office.example.com/", "495")
|
||||||
|
want := "https://office.example.com/Products/Files/Default.aspx#folder=495"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("FolderURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "Berlin")
|
||||||
|
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, "Berlin") || 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"] != "Berlin" {
|
||||||
|
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