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
10 changed files with 53 additions and 50 deletions
Showing only changes of commit ff46aad615 - Show all commits
+24 -18
View File
@@ -557,7 +557,7 @@ Example — convert a portal file (or a local file) to PDF with the native engin
```bash ```bash
export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret> export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret>
oo docs pdf 3684 --out out.pdf # OO file id → PDF 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) oo docs pdf ./report.docx --to pdf # local file → PDF (temp upload, auto-cleanup)
``` ```
@@ -619,35 +619,35 @@ oo cases list
oo crm-tasks categories oo crm-tasks categories
# Deep links & native document conversion # Deep links & native document conversion
oo link 3684 3694 # DocEditor URL for file ids (title + url) oo link 1234 2345 # DocEditor URL for file ids (title + url)
oo docs presigned 3684 # short-lived fetchable URL of an OO file oo docs presigned 1234 # short-lived fetchable URL of an OO file
oo docs pdf 3684 --out out.pdf # OO file → PDF (via DocumentServer) 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 ./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 1234 --stream > out.pdf # pipe: bytes to stdout (alias --pipe)
# Spreadsheet export (sheet-aware; local reader — the DS csv output is first-sheet-only) # 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 1234 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based)
oo docs csv 227 --delimiter ';' # ; | | \t via --delimiter oo docs csv 1234 --delimiter ';' # ; | | \t via --delimiter
oo docs json 227 --sheet 2 # worksheet → JSON rows (first row = header) oo docs json 1234 --sheet 2 # worksheet → JSON rows (first row = header)
oo docs csv ./book.xlsx --sheet 1 --out sheet1.csv 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
# Project team (portal users) CRUD # Project team (portal users) CRUD
oo projects team list 219 oo projects team list 42
oo projects team add 219 <user_id> [<user_id>...] oo projects team add 42 <user_id> [<user_id>...]
oo projects team remove 219 <user_id> oo projects team remove 42 <user_id>
oo projects team set 219 <user_id> [...] # replace team (may lag; verify with list) oo projects team set 42 <user_id> [...] # replace team (may lag; verify with list)
oo projects milestone-delete 38 oo projects milestone-delete 7
# Project documents: fresh re-upload (single clean version) / new version # Project documents: fresh re-upload (single clean version) / new version
oo projects files replace-in 495 ./contract.pdf # hard delete same stem|ext in folder + upload oo projects files replace-in 1 ./contract.pdf # hard delete same stem|ext in folder + upload
oo projects files update 3647 ./contract.docx # overwrite content, same file id oo projects files update 1 ./contract.docx # overwrite content, same file id
# Users lifecycle # Users lifecycle
oo users list ; oo users get <user_id> oo users list ; oo users get <user_id>
oo users create --first Morgane --last Avéus --email morgane@example.com --password '…' oo users create --first Jane --last Doe --email jane.doe@example.com --password '…'
oo users check --login morgane@example.com # verify login (email works when userName 500s) oo users check --login jane.doe@example.com # verify login (email works when userName 500s)
oo users update <user_id> --title "…" --location "…" oo users update <user_id> --title "…" --location "…"
oo users block <user_id> ; oo users unblock <user_id> oo users block <user_id> ; oo users unblock <user_id>
oo users password <user_id> # reads the new password from stdin oo users password <user_id> # reads the new password from stdin
@@ -835,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`) |
@@ -847,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
+4 -4
View File
@@ -103,10 +103,10 @@ func (c *Client) authenticateOnce(ctx context.Context) error {
// token, and the portal error otherwise. // token, and the portal error otherwise.
// //
// OnlyOffice accepts either the userName or the account email as the login. On // OnlyOffice accepts either the userName or the account email as the login. On
// the arc-1 portal (office.produktor.io) the account userName login returns // some portals the account userName login returns HTTP 500 "User authentication
// HTTP 500 "User authentication failed" while the account email succeeds — // failed" while the account email succeeds — confirmed for a freshly created
// confirmed for a freshly created guest user. Use this probe before sharing // guest user. Use this probe before sharing credentials (see `oo users check`),
// credentials (see `oo users check`), and prefer the email as the login. // and prefer the email as the login.
func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error { func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error {
body, err := json.Marshal(Credentials{User: login, Password: password}) body, err := json.Marshal(Credentials{User: login, Password: password})
if err != nil { if err != nil {
+2 -2
View File
@@ -87,7 +87,7 @@ 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. 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" 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 (/ds-vpath is the usual reverse-proxy mount for the DocumentServer). The JWT secret is
$ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, $ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`,
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -334,7 +334,7 @@ Each data row becomes an object keyed by the header cells of that sheet.`,
} }
// 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 standard /ds-vpath reverse-proxy mount.
func docsBaseURL(flag string) string { func docsBaseURL(flag string) string {
if flag != "" { if flag != "" {
return flag return flag
+4 -5
View File
@@ -9,11 +9,10 @@ func init() {
rootCmd.AddCommand(linkCmd()) rootCmd.AddCommand(linkCmd())
} }
// linkCmd prints deep links for file ids. Used to build the third-party // linkCmd prints deep links for file ids. Used to build third-party document
// document packs (e.g. the arc-1 "Lisbon apartment" Info cover embeds links to // packs whose cover embeds links to contracts and supporting files. File ids
// passports, contracts and the income documents). File ids come from // come from `oo projects files list` / `oo dav ls`; `oo projects files
// `oo projects files list` / `oo dav ls`; `oo projects files replace-in` keeps // replace-in` keeps them clean when a document is re-uploaded.
// them clean when a document is re-uploaded.
func linkCmd() *cobra.Command { func linkCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "link FILE_ID [FILE_ID...]", Use: "link FILE_ID [FILE_ID...]",
+2 -3
View File
@@ -159,9 +159,8 @@ func prjFilesReplaceInCmd() *cobra.Command {
delete is permanent) and uploads the local file fresh. Unlike 'update' this delete is permanent) and uploads the local file fresh. Unlike 'update' this
leaves a single clean version. leaves a single clean version.
Why it exists: on the arc-1 portal, repeated 'update' of a shared document Why it exists: repeated 'update' of a shared document accumulated a visible
(the apartment "Info" cover and the Edelweiss contract) accumulated a visible version history and left a stale id. replace-in yields one clean revision; then
version history and 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). 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.`, Note: file ids are server-assigned; a fresh upload gets a new id.`,
+4 -4
View File
@@ -322,10 +322,10 @@ func usersCheckCmd() *cobra.Command {
discards the token. discards the token.
Where this is used: before handing portal credentials to an external party Where this is used: before handing portal credentials to an external party
(e.g. a landlord given read access to the apartment document pack), verify the (e.g. a guest given read access to a document pack), verify the login actually
login actually works. On the arc-1 portal the account email is the reliable works. On some portals the account email is the reliable login identifier — the
login identifier — the userName login fails with 500 for a freshly created userName login fails with 500 for a freshly created user — so share the email,
user, so share the email, not the userName.`, not the userName.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if login == "" { if login == "" {
return fmt.Errorf("--login is required (userName or email)") return fmt.Errorf("--login is required (userName or email)")
+1 -1
View File
@@ -4,7 +4,7 @@ package onlyoffice
// //
// The DocumentServer (the same engine behind the portal's "Download as PDF") // The DocumentServer (the same engine behind the portal's "Download as PDF")
// converts any office format. From a portal-reachable host the converter is // converts any office format. From a portal-reachable host the converter is
// exposed at "<portal>/ds-vpath/converter" (nginx proxy) or directly at // exposed at "<portal>/ds-vpath/converter" (reverse proxy) or directly at
// "http://<docs-server>:8083/converter" (legacy path: /ConvertService.ashx). // "http://<docs-server>:8083/converter" (legacy path: /ConvertService.ashx).
// //
// Flow: PresignedURI(fileId) → Convert(docsBase, secret, req) → download // Flow: PresignedURI(fileId) → Convert(docsBase, secret, req) → download
+8 -9
View File
@@ -2,16 +2,15 @@ package onlyoffice
// Deep links to OnlyOffice portal objects. // Deep links to OnlyOffice portal objects.
// //
// Where this is used: third-party-facing document packs (e.g. the arc-1 // Used by tools that embed per-file links of the form
// office.produktor.io "Lisbon apartment" project) embed per-file links of the // /Products/Files/DocEditor.aspx?fileid=<id> in a cover document or a chat
// form /Products/Files/DocEditor.aspx?fileid=<id> in a cover document and in // message. Those links must be consistent so they match the file ids returned
// chat messages. Those links must be generated consistently so they match the // by `oo projects files list` / `oo link`.
// file ids returned by `oo projects files list` / `oo link`.
// //
// Caveat proven on the arc-1 portal: file ids are server-assigned and a // Caveat: file ids are server-assigned and a re-upload/delete yields a NEW id,
// re-upload/delete yields a NEW id, so an already-shared link can go stale. // so an already-shared link can go stale. Use `oo projects files replace-in`
// Use `oo projects files replace-in` (keep the id clean) and, if a legacy link // (keeps the id stable) and, if a legacy link must keep working, an nginx alias
// must keep working, an nginx alias can 302 the old fileid to the new one. // can 302 the old fileid to the new one.
import ( import (
"fmt" "fmt"
+1 -1
View File
@@ -5,7 +5,7 @@ import "testing"
func TestFileEditorURL(t *testing.T) { func TestFileEditorURL(t *testing.T) {
cases := []struct{ base, id, want string }{ 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", "3651", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3651"},
{"https://office.example.com/", " 3687 ", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3687"}, {"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"}, {"http://localhost:8087", "a/b", "http://localhost:8087/Products/Files/DocEditor.aspx?fileid=a%2Fb"},
} }
for _, c := range cases { for _, c := range cases {
+3 -3
View File
@@ -17,7 +17,7 @@ func TestWorkbookSheetExport(t *testing.T) {
_ = f.SetCellValue("first", "B2", 30) _ = f.SetCellValue("first", "B2", 30)
_, _ = f.NewSheet("second") _, _ = f.NewSheet("second")
_ = f.SetCellValue("second", "A1", "city") _ = f.SetCellValue("second", "A1", "city")
_ = f.SetCellValue("second", "A2", "Lisbon") _ = f.SetCellValue("second", "A2", "Berlin")
var buf bytes.Buffer var buf bytes.Buffer
if err := f.Write(&buf); err != nil { if err := f.Write(&buf); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -34,7 +34,7 @@ func TestWorkbookSheetExport(t *testing.T) {
t.Fatalf("sheet0 csv=%q err=%v", csv0, err) t.Fatalf("sheet0 csv=%q err=%v", csv0, err)
} }
csv1, err := WorkbookSheetCSV(data, 1, 0) csv1, err := WorkbookSheetCSV(data, 1, 0)
if err != nil || !strings.Contains(csv1, "Lisbon") || strings.Contains(csv1, "Alice") { if err != nil || !strings.Contains(csv1, "Berlin") || strings.Contains(csv1, "Alice") {
t.Fatalf("sheet1 csv=%q err=%v", csv1, err) t.Fatalf("sheet1 csv=%q err=%v", csv1, err)
} }
// negative index = first sheet // negative index = first sheet
@@ -44,7 +44,7 @@ func TestWorkbookSheetExport(t *testing.T) {
} }
js, err := WorkbookSheetJSON(data, 1) js, err := WorkbookSheetJSON(data, 1)
if err != nil || len(js) != 1 || js[0]["city"] != "Lisbon" { if err != nil || len(js) != 1 || js[0]["city"] != "Berlin" {
t.Fatalf("sheet1 json=%#v err=%v", js, err) t.Fatalf("sheet1 json=%#v err=%v", js, err)
} }