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
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)
```
@@ -619,35 +619,35 @@ oo cases list
oo crm-tasks categories
# Deep links & native document conversion
oo link 3684 3694 # DocEditor URL for file ids (title + url)
oo docs presigned 3684 # short-lived fetchable URL of an OO file
oo docs pdf 3684 --out out.pdf # OO file → PDF (via DocumentServer)
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 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)
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 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 219
oo projects team add 219 <user_id> [<user_id>...]
oo projects team remove 219 <user_id>
oo projects team set 219 <user_id> [...] # replace team (may lag; verify with list)
oo projects milestone-delete 38
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 495 ./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 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 Morgane --last Avéus --email morgane@example.com --password '…'
oo users check --login morgane@example.com # verify login (email works when userName 500s)
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
@@ -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` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `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` |
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` |
| `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:
`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.
> **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.
//
// 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
// 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.
// 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 {
+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.
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).`,
Args: cobra.MinimumNArgs(1),
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,
// else the portal's /ds-vpath proxy.
// else the standard /ds-vpath reverse-proxy mount.
func docsBaseURL(flag string) string {
if flag != "" {
return flag
+4 -5
View File
@@ -9,11 +9,10 @@ func init() {
rootCmd.AddCommand(linkCmd())
}
// linkCmd prints deep links for file ids. Used to build the third-party
// document packs (e.g. the arc-1 "Lisbon apartment" Info cover embeds links to
// passports, contracts and the income documents). 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.
// 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...]",
+2 -3
View File
@@ -159,9 +159,8 @@ func prjFilesReplaceInCmd() *cobra.Command {
delete is permanent) and uploads the local file fresh. Unlike 'update' this
leaves a single clean version.
Why it exists: on the arc-1 portal, repeated 'update' of a shared document
(the apartment "Info" cover and the Edelweiss contract) accumulated a visible
version history and a stale id. replace-in yields one clean revision; then
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.`,
+4 -4
View File
@@ -322,10 +322,10 @@ func usersCheckCmd() *cobra.Command {
discards the token.
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
login actually works. On the arc-1 portal 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.`,
(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)")
+1 -1
View File
@@ -4,7 +4,7 @@ package onlyoffice
//
// 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
// 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
+8 -9
View File
@@ -2,16 +2,15 @@ package onlyoffice
// Deep links to OnlyOffice portal objects.
//
// Where this is used: third-party-facing document packs (e.g. the arc-1
// office.produktor.io "Lisbon apartment" project) embed per-file links of the
// form /Products/Files/DocEditor.aspx?fileid=<id> in a cover document and in
// chat messages. Those links must be generated consistently so they match the
// file ids returned by `oo projects files list` / `oo link`.
// 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 proven on the arc-1 portal: 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` (keep the id clean) and, if a legacy link
// must keep working, an nginx alias can 302 the old fileid to the new one.
// 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"
+1 -1
View File
@@ -5,7 +5,7 @@ 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/", " 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"},
}
for _, c := range cases {
+3 -3
View File
@@ -17,7 +17,7 @@ func TestWorkbookSheetExport(t *testing.T) {
_ = f.SetCellValue("first", "B2", 30)
_, _ = f.NewSheet("second")
_ = f.SetCellValue("second", "A1", "city")
_ = f.SetCellValue("second", "A2", "Lisbon")
_ = f.SetCellValue("second", "A2", "Berlin")
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatal(err)
@@ -34,7 +34,7 @@ func TestWorkbookSheetExport(t *testing.T) {
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") {
if err != nil || !strings.Contains(csv1, "Berlin") || strings.Contains(csv1, "Alice") {
t.Fatalf("sheet1 csv=%q err=%v", csv1, err)
}
// negative index = first sheet
@@ -44,7 +44,7 @@ func TestWorkbookSheetExport(t *testing.T) {
}
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)
}