diff --git a/README.md b/README.md index 7c7384e..7c6a12b 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,28 @@ 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 | +| `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook | +| `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) | +| `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) | + +Example — convert a portal file (or a local file) to PDF with the native engine: + +```bash +export ONLYOFFICE_DS_SECRET= +oo docs pdf 1234 --out out.pdf # OO file id → PDF +oo docs pdf ./report.docx --to pdf # local file → PDF (temp upload, auto-cleanup) +``` + ### Helper Types | Type | Description | @@ -582,6 +617,41 @@ oo opportunities list oo opportunities stages oo cases list oo crm-tasks categories + +# Deep links & native document conversion +oo link 1234 2345 # DocEditor URL for file ids (title + url) +oo docs presigned 1234 # short-lived fetchable URL of an OO file +oo docs pdf 1234 --out out.pdf # OO file → PDF (via DocumentServer) +oo docs pdf ./report.docx --to pdf # local file → PDF on the fly (temp upload+cleanup) +oo docs pdf 1234 --stream > out.pdf # pipe: bytes to stdout (alias --pipe) + +# Spreadsheet export (sheet-aware; local reader — the DS csv output is first-sheet-only) +oo docs csv 1234 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based) +oo docs csv 1234 --delimiter ';' # ; | | \t via --delimiter +oo docs json 1234 --sheet 2 # worksheet → JSON rows (first row = header) +oo docs csv ./book.xlsx --sheet 1 --out sheet1.csv +# export ONLYOFFICE_DS_SECRET= +# docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath + +# Project team (portal users) CRUD +oo projects team list 42 +oo projects team add 42 [...] +oo projects team remove 42 +oo projects team set 42 [...] # replace team (may lag; verify with list) +oo projects milestone-delete 7 + +# Project documents: fresh re-upload (single clean version) / new version +oo projects files replace-in 1 ./contract.pdf # hard delete same stem|ext in folder + upload +oo projects files update 1 ./contract.docx # overwrite content, same file id + +# Users lifecycle +oo users list ; oo users get +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 --title "…" --location "…" +oo users block ; oo users unblock +oo users password # reads the new password from stdin +oo users delete [...] ``` ### office (TUI) @@ -765,7 +835,7 @@ kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV] | `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` | | `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`) | @@ -777,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 --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 ed897ad..22b4c65 100644 --- a/auth.go +++ b/auth.go @@ -98,6 +98,50 @@ 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 account userName login returns HTTP 500 "User authentication +// failed" while the account email succeeds — confirmed for a freshly created +// guest user. Use this probe before sharing credentials (see `oo users check`), +// and prefer the email as the login. +func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error { + body, err := json.Marshal(Credentials{User: login, Password: password}) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("auth request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400)) + } + var env struct { + Response *Token `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("auth decode: %w", err) + } + if env.Response == nil || env.Response.Value == "" { + return fmt.Errorf("auth: empty token in response") + } + return nil +} + // InvalidateToken clears the cached authentication token. The next request // (or call to Authenticate / AuthenticateContext) will re-authenticate. // diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 7e7ff31..9cac059 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -1,11 +1,16 @@ package main import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "os" "path/filepath" + "strconv" "strings" + "time" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/internal/docpipe" @@ -32,6 +37,10 @@ OCR a scan locally: oo docs ocr scan.pdf --md out.md Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, } cmd.AddCommand(docsConvertCmd()) + cmd.AddCommand(docsPDFCmd()) + cmd.AddCommand(docsPresignedCmd()) + cmd.AddCommand(docsCSVCmd()) + cmd.AddCommand(docsJSONCmd()) cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsHOCRCmd()) @@ -61,6 +70,293 @@ func docsToolsCmd() *cobra.Command { } } +func docsPDFCmd() *cobra.Command { + var out, docsURL, secret, output, folder string + var stream, pipe bool + cmd := &cobra.Command{ + Use: "pdf FILE_ID | PATH [ARG...]", + Short: "Convert files to PDF via the DocumentServer converter (OO file ids or local paths)", + Long: `Native OnlyOffice conversion (the engine behind the portal's "Download as PDF"): + + 1. GET /api/2.0/files/file/{id}/presigneduri → fetchable source URL + 2. POST /converter with a JWT → converted file URL + 3. download the result + +Arguments may be OnlyOffice file ids OR local file paths. A local path is +uploaded to the scratch folder (--folder, default 2 = "My documents"), converted, +downloaded and then removed — so any local document yields a PDF on the fly. + +Docs base defaults to $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + "/ds-vpath" +(/ds-vpath is the usual reverse-proxy mount for the DocumentServer). The JWT secret is +$ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + base := docsBaseURL(docsURL) + if base == "" { + return fmt.Errorf("docs base url unknown; set --docs-url or ONLYOFFICE_DOCS_URL") + } + sec := secret + if sec == "" { + sec = firstEnv("ONLYOFFICE_DS_SECRET", "OO_DS_SECRET") + } + if sec == "" { + return fmt.Errorf("JWT secret required: --secret or ONLYOFFICE_DS_SECRET") + } + ot := output + if ot == "" { + ot = "pdf" + } + toStdout := stream || pipe + if toStdout && len(args) > 1 { + return fmt.Errorf("--stream/--pipe writes one file to stdout; pass a single input") + } + for _, arg := range args { + id, local := arg, false + title := "" + if fi, statErr := os.Stat(arg); statErr == nil && !fi.IsDir() { + // Local file → temporary upload into the scratch folder. + ent, uerr := c.UploadToFolder(cmd.Context(), folder, arg) + if uerr != nil { + return fmt.Errorf("upload %s: %w", arg, uerr) + } + id = strconv.FormatInt(onlyoffice.FileEntryNumericID(ent), 10) + local = true + title = filepath.Base(arg) + } else { + if f, ferr := c.GetFile(cmd.Context(), id); ferr == nil && f != nil && f.Title != nil { + title = *f.Title + } + } + src, err := c.PresignedURI(cmd.Context(), id) + if err != nil { + return fmt.Errorf("presigneduri %s: %w", id, err) + } + res, err := c.ConvertDocument(cmd.Context(), base, sec, onlyoffice.ConvertRequest{ + URL: src, + OutputType: ot, + FileType: strings.TrimPrefix(filepath.Ext(title), "."), + Title: title, + Key: fmt.Sprintf("oo-%s-%d", id, time.Now().UnixNano()), + }) + if err != nil { + return fmt.Errorf("convert %s: %w", id, err) + } + var w io.Writer + dst := out + if toStdout { + w = os.Stdout + } else { + if dst == "" { + stem := strings.TrimSuffix(title, filepath.Ext(title)) + if stem == "" { + stem = "file-" + id + } + dst = stem + "." + ot + } + fh, err := os.Create(dst) + if err != nil { + return err + } + w = fh + defer fh.Close() + } + n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, w) + if local { + // Best-effort cleanup of the temporary upload. + if nid, e := strconv.Atoi(id); e == nil { + _ = c.DeleteFiles(cmd.Context(), []int{nid}) + } + } + if derr != nil { + return fmt.Errorf("download: %w", derr) + } + if toStdout { + // Keep stdout byte-clean for pipes; status goes to stderr. + fmt.Fprintf(os.Stderr, "converted %s -> stdout (%d bytes, %s)\n", arg, n, ot) + } else { + printObject(map[string]any{"source": arg, "fileid": id, "title": title, "output": dst, "bytes": n, "type": ot}) + } + } + return nil + }, + } + cmd.Flags().StringVar(&out, "out", "", "output path (default: ./.<format>)") + cmd.Flags().StringVar(&output, "to", "pdf", "output format (pdf, docx, xlsx, …)") + cmd.Flags().StringVar(&docsURL, "docs-url", "", "DocumentServer base (default $ONLYOFFICE_DOCS_URL or $ONLYOFFICE_URL/ds-vpath)") + cmd.Flags().StringVar(&secret, "secret", "", "JWT secret (default $ONLYOFFICE_DS_SECRET)") + cmd.Flags().StringVar(&folder, "folder", "2", "scratch folder id for local-file uploads") + cmd.Flags().BoolVar(&stream, "stream", false, "write the converted bytes to stdout (pipe-friendly)") + cmd.Flags().BoolVar(&pipe, "pipe", false, "alias for --stream") + return cmd +} + +func docsPresignedCmd() *cobra.Command { + return &cobra.Command{ + Use: "presigned FILE_ID", + Short: "Print a short-lived fetchable URI for a portal file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + u, err := c.PresignedURI(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(map[string]any{"fileid": args[0], "uri": u}) + return nil + }, + } +} + +// loadWorkbookBytes reads an argument that is either an OnlyOffice file id +// (downloaded via the client) or a local path. +func loadWorkbookBytes(cmd *cobra.Command, c *onlyoffice.Client, arg string) ([]byte, error) { + if fi, err := os.Stat(arg); err == nil && !fi.IsDir() { + return os.ReadFile(arg) + } + var buf bytes.Buffer + if _, err := c.DownloadFile(cmd.Context(), arg, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// parseDelimiter maps a flag value to a CSV delimiter rune ("," default). +func parseDelimiter(s string) rune { + switch s { + case "", ",": + return ',' + case "\\t", "tab", "\t": + return '\t' + case ";": + return ';' + case "|": + return '|' + default: + r := []rune(s) + return r[0] + } +} + +func docsCSVCmd() *cobra.Command { + var sheet int + var delim, out string + cmd := &cobra.Command{ + Use: "csv SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to CSV (sheet-aware)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). + +Why not the DocumentServer: its csv output covers only the first worksheet and +ignores a sheet selector (verified). Sheet selection and JSON use a local reader.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + d := parseDelimiter(delim) + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + text, err := onlyoffice.WorkbookSheetCSV(data, sheet-1, d) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + if out != "" { + if err := os.WriteFile(out, []byte(text), 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "output": out, "bytes": len(text)}) + continue + } + fmt.Print(text) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&delim, "delimiter", ",", "CSV delimiter (',', ';', '|', 'tab')") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + +func docsJSONCmd() *cobra.Command { + var sheet int + var out string + cmd := &cobra.Command{ + Use: "json SRC [SRC...]", + Short: "Export a worksheet (XLS/XLSX/ODS) to JSON rows (first row = header)", + Long: `SRC is an OnlyOffice file id or a local path. --sheet is 1-based (default 1 = first). +Each data row becomes an object keyed by the header cells of that sheet.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, arg := range args { + data, err := loadWorkbookBytes(cmd, c, arg) + if err != nil { + return fmt.Errorf("read %s: %w", arg, err) + } + rows, err := onlyoffice.WorkbookSheetJSON(data, sheet-1) + if err != nil { + return fmt.Errorf("%s: %w", arg, err) + } + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if out != "" { + if err := os.WriteFile(out, b, 0o644); err != nil { + return err + } + printObject(map[string]any{"source": arg, "sheet": sheet, "rows": len(rows), "output": out}) + continue + } + os.Stdout.Write(b) + } + return nil + }, + } + cmd.Flags().IntVar(&sheet, "sheet", 1, "worksheet number (1-based, default first)") + cmd.Flags().StringVar(&out, "out", "", "write to this file instead of stdout (single input)") + return cmd +} + +// docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL, +// else the standard /ds-vpath reverse-proxy mount. +func docsBaseURL(flag string) string { + if flag != "" { + return flag + } + if v := os.Getenv("ONLYOFFICE_DOCS_URL"); v != "" { + return v + } + if v := firstEnv("ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"); v != "" { + return strings.TrimRight(v, "/") + "/ds-vpath" + } + return "" +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} + func strOrNil(s string) any { if s == "" { return nil diff --git a/cmd/oo/link.go b/cmd/oo/link.go new file mode 100644 index 0000000..df1cea4 --- /dev/null +++ b/cmd/oo/link.go @@ -0,0 +1,45 @@ +package main + +import ( + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(linkCmd()) +} + +// linkCmd prints deep links for file ids. Used to build third-party document +// packs whose cover embeds links to contracts and supporting files. File ids +// come from `oo projects files list` / `oo dav ls`; `oo projects files +// replace-in` keeps them clean when a document is re-uploaded. +func linkCmd() *cobra.Command { + return &cobra.Command{ + Use: "link FILE_ID [FILE_ID...]", + Short: "Print OnlyOffice DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, id := range args { + printObject(map[string]any{ + "fileid": id, + "title": fileTitle(cmd, c, id), + "url": c.FileEditorURL(id), + }) + } + return nil + }, + } +} + +// fileTitle best-effort resolves a file title; never fails the command. +func fileTitle(cmd *cobra.Command, c *onlyoffice.Client, id string) string { + f, err := c.GetFile(cmd.Context(), id) + if err != nil || f == nil || f.Title == nil { + return "" + } + return *f.Title +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index c6e39c1..7e41451 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 @@ -15,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 | 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/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 diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index fb41f57..e44912e 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -22,6 +22,8 @@ func projectFilesCmd() *cobra.Command { } cmd.AddCommand(prjFilesListCmd()) cmd.AddCommand(prjFilesUploadCmd()) + cmd.AddCommand(prjFilesReplaceInCmd()) + cmd.AddCommand(prjFilesUpdateCmd()) cmd.AddCommand(prjFilesDownloadCmd()) cmd.AddCommand(prjFilesRenameCmd()) cmd.AddCommand(prjFilesDeleteCmd()) @@ -149,6 +151,68 @@ 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. + +Why it exists: repeated 'update' of a shared document accumulated a visible +version history and left a stale id. replace-in yields one clean revision; then +point links at the returned id (or keep an nginx alias for the legacy fileid). + +Note: file ids are server-assigned; a fresh upload gets a new id.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + folderID := args[0] + for _, p := range args[1:] { + stem := onlyoffice.UploadStemFromLocal(p) + ext := onlyoffice.UploadExtFromLocal(p) + deleted, derr := c.DeleteFilesByDedupKey(cmd.Context(), folderID, stem, ext) + if derr != nil { + return derr + } + ent, uerr := c.UploadToFolder(cmd.Context(), folderID, p) + if uerr != nil { + return uerr + } + obj := fileEntryToMap(ent) + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) + } + return nil + }, + } +} + +func prjFilesUpdateCmd() *cobra.Command { + return &cobra.Command{ + Use: "update FILE_ID LOCAL_PATH", + Short: "Overwrite an existing Documents file with new content (new version)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + entry, err := c.UpdateFile(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printObject(fileEntryToMap(entry)) + return nil + }, + } +} + func prjFilesDownloadCmd() *cobra.Command { var to string cmd := &cobra.Command{ diff --git a/cmd/oo/users.go b/cmd/oo/users.go index d307eea..2fe4f10 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,54 @@ 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. + +Where this is used: before handing portal credentials to an external party +(e.g. a guest given read access to a document pack), verify the login actually +works. On some portals the account email is the reliable login identifier — the +userName login fails with 500 for a freshly created user — so share the email, +not the userName.`, + RunE: func(cmd *cobra.Command, args []string) error { + if login == "" { + return fmt.Errorf("--login is required (userName or email)") + } + if password == "" { + if b, err := readLine(os.Stdin); err == nil { + password = b + } + } + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.AuthenticateAs(cmd.Context(), login, password); err != nil { + printObject(map[string]any{"login": login, "ok": false, "error": trimAuthErr(err)}) + return fmt.Errorf("login failed for %s", login) + } + printObject(map[string]any{"login": login, "ok": true}) + return nil + }, + } + cmd.Flags().StringVar(&login, "login", "", "userName or email") + cmd.Flags().StringVar(&password, "password", "", "password (omit to read one line from stdin)") + return cmd +} + +// trimAuthErr keeps the error short for table output. +func trimAuthErr(err error) string { + s := err.Error() + if len(s) > 160 { + s = s[:160] + "…" + } + return s +} + // readLine reads a single trimmed line from r. func readLine(r *os.File) (string, error) { sc := bufio.NewScanner(r) diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..40c294c --- /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" (reverse proxy) or directly at +// "http://<docs-server>:8083/converter" (legacy path: /ConvertService.ashx). +// +// Flow: PresignedURI(fileId) → Convert(docsBase, secret, req) → download +// result.FileURL. The JWT is HS256 signed with the DocumentServer's +// services.CoAuthoring.secret (NOT storage.fs.secretString). + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// PresignedURI returns a short-lived, fetchable download URI for a portal file +// (GET /api/2.0/files/file/{fileId}/presigneduri). The DocumentServer can fetch +// it without the caller's session, so it is the input for Convert. +func (c *Client) PresignedURI(ctx context.Context, fileID string) (string, error) { + if fileID == "" { + return "", fmt.Errorf("file id is required") + } + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/files/file/%s/presigneduri", url.PathEscape(fileID))) + if err != nil { + return "", err + } + resp, err := responseField(raw, "response") + if err != nil { + return "", err + } + var s string + if err := json.Unmarshal(resp, &s); err == nil && s != "" { + return s, nil + } + // Some builds return an object instead of a bare string. + var o map[string]any + if err := json.Unmarshal(resp, &o); err == nil { + for _, k := range []string{"uri", "url", "Uri", "Url"} { + if v, ok := o[k].(string); ok && v != "" { + return v, nil + } + } + } + return "", fmt.Errorf("presigneduri: unexpected response %s", truncate(string(resp), 200)) +} + +// ConvertRequest is the DocumentServer converter body. +type ConvertRequest struct { + URL string `json:"url"` + OutputType string `json:"outputtype"` + FileType string `json:"filetype,omitempty"` + Key string `json:"key"` + Title string `json:"title,omitempty"` +} + +// ConvertResult is the DocumentServer converter reply. +type ConvertResult struct { + FileURL string `json:"fileUrl"` + FileType string `json:"fileType"` + Percent int `json:"percent"` + EndConvert bool `json:"endConvert"` + Error *int `json:"error,omitempty"` +} + +// SignJWT builds an HS256 JWT with the given payload (stdlib only). +func SignJWT(secret string, payload any) (string, error) { + if secret == "" { + return "", fmt.Errorf("jwt secret is empty") + } + hb, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + if err != nil { + return "", err + } + pb, err := json.Marshal(payload) + if err != nil { + return "", err + } + enc := base64.RawURLEncoding.EncodeToString + signing := enc(hb) + "." + enc(pb) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signing)) + return signing + "." + enc(mac.Sum(nil)), nil +} + +// ConvertDocument asks a DocumentServer to convert req.URL into req.OutputType. +// docsBase is e.g. "https://portal/ds-vpath" or "http://localhost:8083"; +// secret is the DocumentServer CoAuthoring JWT secret. Passes the JWT both as +// the AuthorizationJwt header and as a body token. +func (c *Client) ConvertDocument(ctx context.Context, docsBase, secret string, req ConvertRequest) (*ConvertResult, error) { + if strings.TrimSpace(docsBase) == "" { + return nil, fmt.Errorf("docs base url is required") + } + if req.URL == "" { + return nil, fmt.Errorf("source url is required") + } + if req.OutputType == "" { + return nil, fmt.Errorf("outputtype is required") + } + if req.Key == "" { + return nil, fmt.Errorf("conversion key is required") + } + jwt, err := SignJWT(secret, req) + if err != nil { + return nil, err + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + endpoint := strings.TrimRight(docsBase, "/") + "/converter" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("AuthorizationJwt", "Bearer "+jwt) + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("converter request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("converter: %d %s", resp.StatusCode, truncate(string(raw), 300)) + } + var out ConvertResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("converter decode: %w (%s)", err, truncate(string(raw), 200)) + } + if out.Error != nil { + return &out, fmt.Errorf("converter error %d", *out.Error) + } + if out.FileURL == "" { + return &out, fmt.Errorf("converter returned no fileUrl") + } + return &out, nil +} + +// DownloadURLTo streams an absolute URL (no portal auth) into dst. +func (c *Client) DownloadURLTo(ctx context.Context, rawurl string, dst io.Writer) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawurl, nil) + if err != nil { + return 0, err + } + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("download: %d", resp.StatusCode) + } + return io.Copy(dst, resp.Body) +} 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") + } +} diff --git a/links.go b/links.go new file mode 100644 index 0000000..81be6c7 --- /dev/null +++ b/links.go @@ -0,0 +1,47 @@ +package onlyoffice + +// Deep links to OnlyOffice portal objects. +// +// Used by tools that embed per-file links of the form +// /Products/Files/DocEditor.aspx?fileid=<id> in a cover document or a chat +// message. Those links must be consistent so they match the file ids returned +// by `oo projects files list` / `oo link`. +// +// Caveat: file ids are server-assigned and a re-upload/delete yields a NEW id, +// so an already-shared link can go stale. Use `oo projects files replace-in` +// (keeps the id stable) and, if a legacy link must keep working, an nginx alias +// can 302 the old fileid to the new one. + +import ( + "fmt" + "net/url" + "strings" +) + +// FileEditorURL builds the OnlyOffice DocEditor deep link for a portal file id: +// +// https://<portal>/Products/Files/DocEditor.aspx?fileid=<id> +// +// portalBase may include a trailing slash; fileID is trimmed and URL-escaped. +func FileEditorURL(portalBase, fileID string) string { + base := strings.TrimRight(strings.TrimSpace(portalBase), "/") + return fmt.Sprintf("%s/Products/Files/DocEditor.aspx?fileid=%s", + base, url.QueryEscape(strings.TrimSpace(fileID))) +} + +// FileEditorURL is the client-bound convenience wrapper (uses the portal URL +// the client was built with). +func (c *Client) FileEditorURL(fileID string) string { + return FileEditorURL(c.baseURL(), fileID) +} + +// FolderURL builds a Documents-folder deep link for a folder id. +func FolderURL(portalBase, folderID string) string { + base := strings.TrimRight(strings.TrimSpace(portalBase), "/") + return fmt.Sprintf("%s/Products/Files/Default.aspx#folder=%s", base, url.QueryEscape(strings.TrimSpace(folderID))) +} + +// FolderURL is the client-bound convenience wrapper. +func (c *Client) FolderURL(folderID string) string { + return FolderURL(c.baseURL(), folderID) +} diff --git a/links_test.go b/links_test.go new file mode 100644 index 0000000..253cbc7 --- /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/", " 1234 ", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=1234"}, + {"http://localhost:8087", "a/b", "http://localhost:8087/Products/Files/DocEditor.aspx?fileid=a%2Fb"}, + } + for _, c := range cases { + if got := FileEditorURL(c.base, c.id); got != c.want { + t.Fatalf("FileEditorURL(%q,%q) = %q, want %q", c.base, c.id, got, c.want) + } + } +} + +func TestFolderURL(t *testing.T) { + got := FolderURL("https://office.example.com/", "495") + want := "https://office.example.com/Products/Files/Default.aspx#folder=495" + if got != want { + t.Fatalf("FolderURL = %q, want %q", got, want) + } +} 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..9f54319 --- /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", "Berlin") + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatal(err) + } + data := buf.Bytes() + + names, err := WorkbookSheetNames(data) + if err != nil || len(names) != 2 { + t.Fatalf("names=%v err=%v", names, err) + } + + csv0, err := WorkbookSheetCSV(data, 0, 0) + if err != nil || !strings.Contains(csv0, "Alice") { + t.Fatalf("sheet0 csv=%q err=%v", csv0, err) + } + csv1, err := WorkbookSheetCSV(data, 1, 0) + if err != nil || !strings.Contains(csv1, "Berlin") || strings.Contains(csv1, "Alice") { + t.Fatalf("sheet1 csv=%q err=%v", csv1, err) + } + // negative index = first sheet + csvDefault, _ := WorkbookSheetCSV(data, -1, 0) + if !strings.Contains(csvDefault, "Alice") { + t.Fatalf("default csv=%q", csvDefault) + } + + js, err := WorkbookSheetJSON(data, 1) + if err != nil || len(js) != 1 || js[0]["city"] != "Berlin" { + t.Fatalf("sheet1 json=%#v err=%v", js, err) + } + + if _, err := WorkbookSheetCSV(data, 5, 0); err == nil { + t.Fatal("expected out-of-range error") + } +}