From e323a433174beed881861f11cf4da9f5872c12a9 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 14:34:40 +0100 Subject: [PATCH 1/9] feat(oo): projects milestone-delete --- cmd/oo/projects.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go index 52004e2..9190721 100644 --- a/cmd/oo/projects.go +++ b/cmd/oo/projects.go @@ -24,6 +24,7 @@ func init() { projectsCmd.AddCommand(prjGetCmd()) projectsCmd.AddCommand(prjMilestonesCmd()) projectsCmd.AddCommand(prjMilestoneCreateCmd()) + projectsCmd.AddCommand(prjMilestoneDeleteCmd()) projectsCmd.AddCommand(prjCreateCmd()) projectsCmd.AddCommand(prjUpdateCmd()) projectsCmd.AddCommand(prjDeleteCmd()) @@ -297,6 +298,32 @@ func prjMilestoneCreateCmd() *cobra.Command { 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 { var desc, resp string var country, company string From ee8e88fb9bf385b5f7e1e00139c21c718825bb31 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 15:57:50 +0100 Subject: [PATCH 2/9] feat(oo): projects files update (overwrite existing file content) --- cmd/oo/projects_files.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index fb41f57..fe18d6c 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -22,6 +22,7 @@ func projectFilesCmd() *cobra.Command { } cmd.AddCommand(prjFilesListCmd()) cmd.AddCommand(prjFilesUploadCmd()) + cmd.AddCommand(prjFilesUpdateCmd()) cmd.AddCommand(prjFilesDownloadCmd()) cmd.AddCommand(prjFilesRenameCmd()) cmd.AddCommand(prjFilesDeleteCmd()) @@ -149,6 +150,26 @@ Pass --no-replace to fail when the name is taken; --allow-duplicate to always cr return cmd } +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 { var to string cmd := &cobra.Command{ From df38b750111e4a17826645c03204b5f942a3c924 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 19:48:19 +0100 Subject: [PATCH 3/9] feat(oo): file deep links, login check, replace-in (fresh upload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link: print Products/Files/DocEditor.aspx?fileid=… deep links (title+url) - lib: FileEditorURL/FolderURL helpers (+tests) - users check: verify credentials (userName vs email) via authentication.json - projects files replace-in FOLDER_ID FILE...: hard delete same stem|ext in a folder + fresh upload → single clean version (no version history) --- auth.go | 42 ++++++++++++++++++++++++++++++++++++++ cmd/oo/link.go | 41 +++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 5 +++-- cmd/oo/projects_files.go | 39 +++++++++++++++++++++++++++++++++++ cmd/oo/users.go | 44 ++++++++++++++++++++++++++++++++++++++++ links.go | 37 +++++++++++++++++++++++++++++++++ links_test.go | 24 ++++++++++++++++++++++ 7 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 cmd/oo/link.go create mode 100644 links.go create mode 100644 links_test.go diff --git a/auth.go b/auth.go index ed897ad..1057743 100644 --- a/auth.go +++ b/auth.go @@ -98,6 +98,48 @@ func (c *Client) authenticateOnce(ctx context.Context) error { 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 userName login fails while the email works — use this probe +// to tell them apart before sharing credentials. +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 // (or call to Authenticate / AuthenticateContext) will re-authenticate. // diff --git a/cmd/oo/link.go b/cmd/oo/link.go new file mode 100644 index 0000000..05aa00e --- /dev/null +++ b/cmd/oo/link.go @@ -0,0 +1,41 @@ +package main + +import ( + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(linkCmd()) +} + +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 +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index c6e39c1..bdfa068 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -3,9 +3,10 @@ // Command tree is subject-based (mirrors the library split and the `tea` CLI): // // 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 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 persons list | create | delete | dedupe // oo companies list | create | delete | dedupe | dedupe-persons diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index fe18d6c..c6dbc57 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -22,6 +22,7 @@ func projectFilesCmd() *cobra.Command { } cmd.AddCommand(prjFilesListCmd()) cmd.AddCommand(prjFilesUploadCmd()) + cmd.AddCommand(prjFilesReplaceInCmd()) cmd.AddCommand(prjFilesUpdateCmd()) cmd.AddCommand(prjFilesDownloadCmd()) cmd.AddCommand(prjFilesRenameCmd()) @@ -150,6 +151,44 @@ Pass --no-replace to fail when the name is taken; --allow-duplicate to always cr 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, which matters when the file id is shared. + +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", diff --git a/cmd/oo/users.go b/cmd/oo/users.go index d307eea..9fa1b69 100644 --- a/cmd/oo/users.go +++ b/cmd/oo/users.go @@ -26,6 +26,7 @@ func init() { usersCmd.AddCommand(usersBlockCmd()) usersCmd.AddCommand(usersUnblockCmd()) usersCmd.AddCommand(usersPasswordCmd()) + usersCmd.AddCommand(usersCheckCmd()) rootCmd.AddCommand(whoamiCmd()) } @@ -312,6 +313,49 @@ func usersPasswordCmd() *cobra.Command { 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. On this portal the account email is the reliable login +identifier (userName login may fail); use this before sharing credentials.`, + 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. func readLine(r *os.File) (string, error) { sc := bufio.NewScanner(r) diff --git a/links.go b/links.go new file mode 100644 index 0000000..eb22fab --- /dev/null +++ b/links.go @@ -0,0 +1,37 @@ +package onlyoffice + +// Deep links to OnlyOffice portal objects. + +import ( + "fmt" + "net/url" + "strings" +) + +// FileEditorURL builds the OnlyOffice DocEditor deep link for a portal file id: +// +// https:///Products/Files/DocEditor.aspx?fileid= +// +// 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) +} diff --git a/links_test.go b/links_test.go new file mode 100644 index 0000000..e443e19 --- /dev/null +++ b/links_test.go @@ -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/", " 3687 ", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3687"}, + {"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) + } +} From 520d6d05c8dc10bdde09afe2118d3471ce71980f Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 19:56:54 +0100 Subject: [PATCH 4/9] docs(oo): comment where the new helpers are used - link.go/links.go: third-party doc packs (fileid links), id-stability caveat - auth.go AuthenticateAs / users check: email-vs-userName login finding - projects files replace-in: version-history rationale --- auth.go | 6 ++++-- cmd/oo/link.go | 5 +++++ cmd/oo/projects_files.go | 7 ++++++- cmd/oo/users.go | 9 +++++++-- links.go | 11 +++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/auth.go b/auth.go index 1057743..5a11348 100644 --- a/auth.go +++ b/auth.go @@ -103,8 +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 -// some portals the userName login fails while the email works — use this probe -// to tell them apart before sharing credentials. +// 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. func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error { body, err := json.Marshal(Credentials{User: login, Password: password}) if err != nil { diff --git a/cmd/oo/link.go b/cmd/oo/link.go index 05aa00e..34a8eae 100644 --- a/cmd/oo/link.go +++ b/cmd/oo/link.go @@ -9,6 +9,11 @@ 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. func linkCmd() *cobra.Command { return &cobra.Command{ Use: "link FILE_ID [FILE_ID...]", diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index c6dbc57..9b63d71 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -157,7 +157,12 @@ func prjFilesReplaceInCmd() *cobra.Command { 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, which matters when the file id is shared. +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 +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), diff --git a/cmd/oo/users.go b/cmd/oo/users.go index 9fa1b69..5fafe58 100644 --- a/cmd/oo/users.go +++ b/cmd/oo/users.go @@ -319,8 +319,13 @@ func usersCheckCmd() *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. On this portal the account email is the reliable login -identifier (userName login may fail); use this before sharing credentials.`, +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.`, RunE: func(cmd *cobra.Command, args []string) error { if login == "" { return fmt.Errorf("--login is required (userName or email)") diff --git a/links.go b/links.go index eb22fab..3439608 100644 --- a/links.go +++ b/links.go @@ -1,6 +1,17 @@ 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= 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`. +// +// 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. import ( "fmt" From 58b905cc49a0a67c4d2c79b227a5adc6d5a29e21 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 20:32:26 +0100 Subject: [PATCH 5/9] feat(oo): native document conversion (docs pdf/presigned) - lib: PresignedURI, SignJWT (HS256, stdlib), ConvertDocument (DocumentServer /converter; legacy /ConvertService.ashx), DownloadURLTo - oo docs pdf FILE_ID|PATH...: OO file ids or local files (temp upload to --folder, convert, download, cleanup) -> PDF/other - oo docs presigned FILE_ID - docs base from $ONLYOFFICE_DOCS_URL else $ONLYOFFICE_URL/ds-vpath; JWT secret from $ONLYOFFICE_DS_SECRET (DocumentServer CoAuthoring secret) - test: SignJWT --- cmd/oo/docs.go | 154 +++++++++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 2 +- convert.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ convert_test.go | 24 +++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 convert.go create mode 100644 convert_test.go diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 7e7ff31..eed7aee 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" + "time" onlyoffice "github.com/eslider/go-onlyoffice" "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`, } cmd.AddCommand(docsConvertCmd()) + cmd.AddCommand(docsPDFCmd()) + cmd.AddCommand(docsPresignedCmd()) cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) 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 /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: ./.<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 { if s == "" { return nil diff --git a/cmd/oo/main.go b/cmd/oo/main.go index bdfa068..00b6de1 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -16,7 +16,7 @@ // oo crm cleanup // 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 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 dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..04b953f --- /dev/null +++ b/convert.go @@ -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) +} diff --git a/convert_test.go b/convert_test.go new file mode 100644 index 0000000..adf56b7 --- /dev/null +++ b/convert_test.go @@ -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") + } +} From 4515a88fe82c80a449845b0678cfa543cc2fced8 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev <eslider@gmail.com> Date: Tue, 22 Sep 2026 21:04:43 +0100 Subject: [PATCH 6/9] docs(readme): examples for links, conversion, team/users CRUD --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index 7c7384e..00d5e26 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,11 @@ type Task struct { | `UpdateProject(req)` | Update project details | | `DeleteProject(id)` | Delete a project | | `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 @@ -502,6 +507,14 @@ type Task struct { | Method | Description | |---|---| | `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 @@ -526,6 +539,25 @@ type Task struct { | `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) | +### 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 | + +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 3684 --out out.pdf # OO file id → PDF +oo docs pdf ./report.docx --to pdf # local file → PDF (temp upload, auto-cleanup) +``` + ### Helper Types | Type | Description | @@ -582,6 +614,34 @@ oo opportunities list oo opportunities stages 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 docs pdf ./report.docx --to pdf # local file → PDF on the fly (temp upload+cleanup) +# 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 + +# 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 + +# 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 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) From 7c089a64399ef9b6a856be9f565fc0ffcc6721b0 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev <eslider@gmail.com> Date: Tue, 22 Sep 2026 21:06:08 +0100 Subject: [PATCH 7/9] feat(oo): docs pdf --stream/--pipe (converted bytes to stdout) --- README.md | 1 + cmd/oo/docs.go | 43 +++++++++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 00d5e26..b078e32 100644 --- a/README.md +++ b/README.md @@ -620,6 +620,7 @@ oo link 3684 3694 # DocEditor URL for file ids (title + u 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 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) # export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret> # docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index eed7aee..6484738 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "os" "path/filepath" "strconv" @@ -67,6 +68,7 @@ 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)", @@ -104,6 +106,10 @@ $ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, 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 := "" @@ -135,20 +141,26 @@ $ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, if err != nil { return fmt.Errorf("convert %s: %w", id, err) } + var w io.Writer dst := out - if dst == "" { - stem := strings.TrimSuffix(title, filepath.Ext(title)) - if stem == "" { - stem = "file-" + id + if toStdout { + w = os.Stdout + } else { + if dst == "" { + stem := strings.TrimSuffix(title, filepath.Ext(title)) + if stem == "" { + stem = "file-" + id + } + dst = stem + "." + ot } - dst = stem + "." + ot + fh, err := os.Create(dst) + if err != nil { + return err + } + w = fh + defer fh.Close() } - f, err := os.Create(dst) - if err != nil { - return err - } - n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, f) - f.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 { @@ -158,7 +170,12 @@ $ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, 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}) + 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 }, @@ -168,6 +185,8 @@ $ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, 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 } From 5e73f6c335d44af1cf707074b86167fee8e510ad Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev <eslider@gmail.com> Date: Tue, 22 Sep 2026 21:09:45 +0100 Subject: [PATCH 8/9] feat(oo): sheet-aware spreadsheet export (docs csv/json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib: WorkbookSheetNames/CSV/JSON (excelize) — sheet selection + JSON rows - oo docs csv SRC... [--sheet N] [--delimiter ,|;|||tab] [--out] - oo docs json SRC... [--sheet N] [--out] - SRC = OO file id or local path; --sheet is 1-based (default first) - DS csv output is first-sheet-only (verified) -> local reader for selection/JSON - test: TestWorkbookSheetExport --- README.md | 9 ++++ cmd/oo/docs.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 2 +- sheets.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ sheets_test.go | 54 ++++++++++++++++++++++ 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 sheets.go create mode 100644 sheets_test.go diff --git a/README.md b/README.md index b078e32..5e78088 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,9 @@ type Task struct { | `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: @@ -621,6 +624,12 @@ oo docs presigned 3684 # short-lived fetchable URL of an OO fi oo docs pdf 3684 --out out.pdf # OO file → PDF (via DocumentServer) oo docs pdf ./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) + +# Spreadsheet export (sheet-aware; local reader — the DS csv output is first-sheet-only) +oo docs csv 227 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based) +oo docs csv 227 --delimiter ';' # ; | | \t via --delimiter +oo docs json 227 --sheet 2 # worksheet → JSON rows (first row = header) +oo docs csv ./book.xlsx --sheet 1 --out sheet1.csv # export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret> # docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 6484738..1f206c5 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -37,6 +39,8 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, cmd.AddCommand(docsConvertCmd()) cmd.AddCommand(docsPDFCmd()) cmd.AddCommand(docsPresignedCmd()) + cmd.AddCommand(docsCSVCmd()) + cmd.AddCommand(docsJSONCmd()) cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsHOCRCmd()) @@ -210,6 +214,125 @@ func docsPresignedCmd() *cobra.Command { } } +// loadWorkbookBytes reads an argument that is either an OnlyOffice file id +// (downloaded via the client) or a local path. +func loadWorkbookBytes(cmd *cobra.Command, c *onlyoffice.Client, arg string) ([]byte, error) { + if fi, err := os.Stat(arg); err == nil && !fi.IsDir() { + return os.ReadFile(arg) + } + var buf bytes.Buffer + if _, err := c.DownloadFile(cmd.Context(), arg, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// parseDelimiter maps a flag value to a CSV delimiter rune ("," default). +func parseDelimiter(s string) rune { + switch s { + case "", ",": + return ',' + case "\\t", "tab", "\t": + return '\t' + case ";": + return ';' + case "|": + return '|' + default: + r := []rune(s) + return r[0] + } +} + +func docsCSVCmd() *cobra.Command { + var sheet int + var delim, out string + cmd := &cobra.Command{ + Use: "csv SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to CSV (sheet-aware)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). + +Why not the DocumentServer: its csv output covers only the first worksheet and +ignores a sheet selector (verified). Sheet selection and JSON use a local reader.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + d := parseDelimiter(delim) + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + text, err := onlyoffice.WorkbookSheetCSV(data, sheet-1, d) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + if out != "" { + if err := os.WriteFile(out, []byte(text), 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "output": out, "bytes": len(text)}) + continue + } + fmt.Print(text) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&delim, "delimiter", ",", "CSV delimiter (',', ';', '|', 'tab')") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + +func docsJSONCmd() *cobra.Command { + var sheet int + var out string + cmd := &cobra.Command{ + Use: "json SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to JSON rows (first row = header)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). +Each data row becomes an object keyed by the header cells of that sheet.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + rows, err := onlyoffice.WorkbookSheetJSON(data, sheet-1) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if out != "" { + if err := os.WriteFile(out, b, 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "rows": len(rows), "output": out}) + continue + } + os.Stdout.Write(b) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + // docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL, // else the portal's /ds-vpath proxy. func docsBaseURL(flag string) string { diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 00b6de1..7e41451 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -16,7 +16,7 @@ // oo crm cleanup // 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 docs tools | convert | pdf | presigned | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx +// oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx // oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird // oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] diff --git a/sheets.go b/sheets.go new file mode 100644 index 0000000..9bd617f --- /dev/null +++ b/sheets.go @@ -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 +} diff --git a/sheets_test.go b/sheets_test.go new file mode 100644 index 0000000..feee720 --- /dev/null +++ b/sheets_test.go @@ -0,0 +1,54 @@ +package onlyoffice + +import ( + "bytes" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestWorkbookSheetExport(t *testing.T) { + f := excelize.NewFile() + f.SetSheetName("Sheet1", "first") + _ = f.SetCellValue("first", "A1", "name") + _ = f.SetCellValue("first", "B1", "age") + _ = f.SetCellValue("first", "A2", "Alice") + _ = f.SetCellValue("first", "B2", 30) + _, _ = f.NewSheet("second") + _ = f.SetCellValue("second", "A1", "city") + _ = f.SetCellValue("second", "A2", "Lisbon") + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatal(err) + } + data := buf.Bytes() + + names, err := WorkbookSheetNames(data) + if err != nil || len(names) != 2 { + t.Fatalf("names=%v err=%v", names, err) + } + + csv0, err := WorkbookSheetCSV(data, 0, 0) + if err != nil || !strings.Contains(csv0, "Alice") { + t.Fatalf("sheet0 csv=%q err=%v", csv0, err) + } + csv1, err := WorkbookSheetCSV(data, 1, 0) + if err != nil || !strings.Contains(csv1, "Lisbon") || strings.Contains(csv1, "Alice") { + t.Fatalf("sheet1 csv=%q err=%v", csv1, err) + } + // negative index = first sheet + csvDefault, _ := WorkbookSheetCSV(data, -1, 0) + if !strings.Contains(csvDefault, "Alice") { + t.Fatalf("default csv=%q", csvDefault) + } + + js, err := WorkbookSheetJSON(data, 1) + if err != nil || len(js) != 1 || js[0]["city"] != "Lisbon" { + t.Fatalf("sheet1 json=%#v err=%v", js, err) + } + + if _, err := WorkbookSheetCSV(data, 5, 0); err == nil { + t.Fatal("expected out-of-range error") + } +} From ff46aad6159e3de24f815a5eea35aad1b2c01dbc Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev <eslider@gmail.com> Date: Tue, 22 Sep 2026 22:41:58 +0100 Subject: [PATCH 9/9] chore: keep the re-landed feature files showroom-safe The conversion / link / sheet commits predate the tree cleanup, so their comments and README/test examples still carried internal refs. Re-apply the genericization (no internal hosts, personal names or client file ids). --- README.md | 42 +++++++++++++++++++++++----------------- auth.go | 8 ++++---- cmd/oo/docs.go | 4 ++-- cmd/oo/link.go | 9 ++++----- cmd/oo/projects_files.go | 5 ++--- cmd/oo/users.go | 8 ++++---- convert.go | 2 +- links.go | 17 ++++++++-------- links_test.go | 2 +- sheets_test.go | 6 +++--- 10 files changed, 53 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 5e78088..7c6a12b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/auth.go b/auth.go index 5a11348..22b4c65 100644 --- a/auth.go +++ b/auth.go @@ -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 { diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 1f206c5..9cac059 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -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 diff --git a/cmd/oo/link.go b/cmd/oo/link.go index 34a8eae..df1cea4 100644 --- a/cmd/oo/link.go +++ b/cmd/oo/link.go @@ -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...]", diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index 9b63d71..e44912e 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -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.`, diff --git a/cmd/oo/users.go b/cmd/oo/users.go index 5fafe58..2fe4f10 100644 --- a/cmd/oo/users.go +++ b/cmd/oo/users.go @@ -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)") diff --git a/convert.go b/convert.go index 04b953f..40c294c 100644 --- a/convert.go +++ b/convert.go @@ -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 diff --git a/links.go b/links.go index 3439608..81be6c7 100644 --- a/links.go +++ b/links.go @@ -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" diff --git a/links_test.go b/links_test.go index e443e19..253cbc7 100644 --- a/links_test.go +++ b/links_test.go @@ -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 { diff --git a/sheets_test.go b/sheets_test.go index feee720..9f54319 100644 --- a/sheets_test.go +++ b/sheets_test.go @@ -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) }