Compare commits

..
15 Commits
Author SHA1 Message Date
eSlider c576bfe2ee feat(files): Documents Dav ops + UpdateFile, fileops errors (#152)
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 1m17s
Tests / Test (Go 1.25) (pull_request) Successful in 1m20s
2026-09-14 12:37:57 +00:00
eSliderandGitHub 7f56332285 Merge pull request #49 from eSlider/sync/gitea-main-20260904
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 4s
Tests / Test (Go 1.25) (push) Successful in 2m4s
Tests / Test (Go stable) (push) Successful in 2m4s
sync: gitea main (sortBy fix #18 + funding)
2026-09-04 15:47:06 +01:00
eSlider a5807b9c31 Merge pull request 'docs(funding): eSlider support links (reverse-import GitHub)' (#19) from docs/funding-github into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 13s
Tests / Test (Go stable) (push) Successful in 30s
Tests / Test (Go 1.25) (push) Successful in 32s
2026-09-04 12:31:47 +01:00
eSlider d650a16a36 docs(funding): eSlider support links (reverse-import GitHub e9c969a)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go stable) (pull_request) Successful in 34s
Tests / Test (Go 1.25) (pull_request) Successful in 37s
2026-09-04 12:29:45 +01:00
eSlider 3da11586f9 Merge pull request 'fix(crm): детерминированный sortBy=id в постраничных списках контактов' (#18) from fix/crm-filter-sortby into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 10s
Tests / Test (Go 1.25) (push) Successful in 34s
Tests / Test (Go stable) (push) Successful in 1m38s
2026-09-03 13:14:36 +01:00
eSlider e9c969a89c funding: eSlider support links (sponsors, ko-fi, liberapay, patreon, polar) 2026-09-03 05:20:18 +01:00
eSlider 4d91726179 fix(crm): deterministic sortBy=id in contact paged lists
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 26s
Tests / Test (Go stable) (pull_request) Successful in 1m21s
2026-09-03 04:50:15 +01:00
eSlider 4d8af7a2fe feat(crm): add UpdateContactName and CloseCRMTask helpers
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 6s
Tests / Test (Go stable) (push) Successful in 28s
Tests / Test (Go 1.25) (push) Successful in 38s
- UpdateContactName renames a company contact via PUT /crm/contact/company/{id}
- CloseCRMTask closes a CRM task via PUT /crm/task/{id}/close.json
2026-08-31 11:38:50 +01:00
eSlider 34745e349e chore(release): trigger CI (#16)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 26s
Tests / Test (Go stable) (pull_request) Successful in 28s
2026-08-31 11:38:48 +01:00
eSliderandGitHub dfea57a57b Merge pull request #46 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 23s
Tests / Test (Go stable) (pull_request) Successful in 26s
Release / GoReleaser (push) Skipped
chore(main): release 0.17.0
2026-08-31 11:34:41 +01:00
github-actions[bot]andGitHub 8595c17f25 chore(main): release 0.17.0 2026-08-31 10:34:21 +00:00
eSlider 61da2fb88b Merge pull request 'fix(files): upsert uploads by default + dedupe project root (#45)' (#15) from fix/docs-upsert-dedupe into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 5s
Tests / Test (Go stable) (push) Successful in 24s
Tests / Test (Go 1.25) (push) Successful in 31s
2026-08-31 11:33:51 +01:00
eSliderandCursor 24ca144b22 fix(files): upsert uploads by default and dedupe project root
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 25s
Tests / Test (Go 1.25) (pull_request) Successful in 27s
OnlyOffice allows duplicate stem|ext in the same folder; agents hit this
via projects files upload and put-md without --folder. Default all upload
paths to replace-by-stem, add no-clobber via --no-replace, and scan
projectFolder in dedupe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:18:54 +01:00
eSlider 93828ee19d Merge pull request 'feat(mailsync): FetchMailFolder — integration-layer mail walk for ETL consumers (2dph)' (#4) from feat/2dph-mail-integration-layer into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 4s
Tests / Test (Go 1.25) (push) Successful in 29s
Tests / Test (Go stable) (push) Successful in 35s
2026-08-31 10:08:19 +01:00
mdx-1andeSlider 35f0cb8d20 feat(mailsync): FetchMailFolder — integration-layer walk for ETL consumers
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 24s
Tests / Test (Go stable) (pull_request) Successful in 40s
Adds the high-level mail folder walk that sync pipelines need on top of
the raw mail API (list -> get -> download-attachment), so consumers stop
re-implementing it against private client copies.

  type MailSyncMessage struct { ID, Folder, Subject, From, Date, IsNew,
                                HasAttachments, Attachments }
  type MailSyncAttachment struct { ID, Name, Size, Body }
  func (c *Client) FetchMailFolder(ctx, folderID, MailSyncOptions)
                                   ([]MailSyncMessage, error)

Options: Limit / StartIndex for checkpointed walks, FetchBodies to
eagerly download attachment bytes via download.ashx (session-cookie path).

Hydration details:
- list items may omit the attachment array; when hasAttachments is set
  the full record is fetched and its attachments merged
- attachment ids accepted from id/fileId/attachmentId variants
- timestamps parsed from RFC3339 (any fractional digits) and
  second-precision forms

This is the first step of the 2dph integration layer (#1): the brain's
mail-ingest pipeline can now drop its private OOClient copy and consume
this canonical walk directly.

Tests: httptest-backed coverage for pagination, hydration with
full-record fallback, body download incl. auth-cookie requirement,
Limit/StartIndex windows, timestamp parsing.
2026-08-31 10:05:47 +01:00
16 changed files with 920 additions and 87 deletions
+6
View File
@@ -0,0 +1,6 @@
github: eSlider
ko_fi: eslider
liberapay: eslider
patreon: eslider
custom:
- https://polar.sh/eslider
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.16.0" ".": "0.17.0"
} }
+1 -1
View File
@@ -28,7 +28,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them. - Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason. - Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain. - CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`). - **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). All upload paths default to **upsert** by `stem|ext` (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command. - Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only. - No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`. - Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
+12
View File
@@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## Unreleased
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
### Features
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
### Bug Fixes
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30) ## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
### Features ### Features
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"fmt"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(davCmd())
}
// davCmd exposes the Documents module through the same Dav calls that back
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
// fileops/move call without those params silently ignores (200 without move).
func davCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "dav",
Short: "Documents module by folder/file id (oo-webdav proven path)",
}
cmd.AddCommand(davLsCmd())
cmd.AddCommand(davMoveCmd())
cmd.AddCommand(davCopyCmd())
cmd.AddCommand(davMkdirCmd())
cmd.AddCommand(davRenameFileCmd())
cmd.AddCommand(davRenameFolderCmd())
cmd.AddCommand(davDownloadCmd())
cmd.AddCommand(davFileOpsCmd())
return cmd
}
func davLsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "ls FOLDER_ID",
Short: "List a Documents folder (@root for virtual sections)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
if args[0] == "@root" {
sections, err := c.ListDavSections(ctx)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(sections))
for _, s := range sections {
rows = append(rows, map[string]any{
"id": s.ID,
"title": s.Title,
"filesCount": s.FilesCount,
"foldersCount": s.FoldersCount,
})
}
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
return nil
}
l, err := c.ListDavFolder(ctx, args[0])
if err != nil {
return err
}
if outputFormat == "json" {
folders := make([]map[string]any, 0, len(l.Folders))
for _, f := range l.Folders {
folders = append(folders, map[string]any{
"id": f.ID,
"title": f.Title,
"filesCount": f.FilesCount,
"foldersCount": f.FoldersCount,
})
}
files := make([]map[string]any, 0, len(l.Files))
for _, f := range l.Files {
files = append(files, map[string]any{
"id": f.ID,
"title": f.Title,
"size": f.Size,
"updated": f.Updated,
})
}
printObject(map[string]any{"folders": folders, "files": files})
return nil
}
if len(l.Folders) > 0 {
frows := make([]map[string]any, 0, len(l.Folders))
for _, f := range l.Folders {
frows = append(frows, map[string]any{
"id": f.ID,
"title": f.Title,
"filesCount": f.FilesCount,
"foldersCount": f.FoldersCount,
})
}
if outputFormat == "table" {
fmt.Println("folders:")
}
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
}
rows := make([]map[string]any, 0, len(l.Files))
for _, f := range l.Files {
rows = append(rows, map[string]any{
"id": f.ID,
"title": f.Title,
"size": f.Size,
"updated": f.Updated,
})
}
if outputFormat == "table" {
fmt.Println("files:")
}
printTable([]string{"id", "title", "size", "updated"}, rows)
return nil
},
}
return cmd
}
func davMoveCmd() *cobra.Command {
var folderIDs []string
cmd := &cobra.Command{
Use: "move DEST_FOLDER_ID FILE_ID [FILE_ID...]",
Short: "Move file(s) into a Documents folder (resolveType=Skip, holdResult)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
return err
}
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
return nil
},
}
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to move along with the files")
return cmd
}
func davCopyCmd() *cobra.Command {
var folderIDs []string
cmd := &cobra.Command{
Use: "copy DEST_FOLDER_ID FILE_ID [FILE_ID...]",
Short: "Copy file(s) into a Documents folder (conflictResolveType=Skip)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
return err
}
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
return nil
},
}
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to copy along with the files")
return cmd
}
func davMkdirCmd() *cobra.Command {
return &cobra.Command{
Use: "mkdir PARENT_FOLDER_ID TITLE",
Short: "Create a subfolder in Documents",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(map[string]any{"id": f.ID, "title": f.Title, "parent": args[0]})
return nil
},
}
}
func davRenameFileCmd() *cobra.Command {
return &cobra.Command{
Use: "rename-file FILE_ID NEW_TITLE",
Short: "Rename a Documents file (include extension in NEW_TITLE)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil {
return err
}
printObject(map[string]any{"id": args[0], "title": args[1]})
return nil
},
}
}
func davRenameFolderCmd() *cobra.Command {
return &cobra.Command{
Use: "rename-folder FOLDER_ID NEW_TITLE",
Short: "Rename a Documents folder",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil {
return err
}
printObject(map[string]any{"id": args[0], "title": args[1]})
return nil
},
}
}
func davDownloadCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "download FILE_ID",
Short: "Download Documents file bytes (default path: ./<title>)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
f, err := c.GetFile(ctx, args[0])
if err != nil {
return err
}
path := to
if path == "" {
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
n, err := c.DownloadDavFile(ctx, args[0], out)
if err != nil {
_ = os.Remove(path)
return err
}
printObject(map[string]any{"path": path, "bytes": n})
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
return cmd
}
func davFileOpsCmd() *cobra.Command {
return &cobra.Command{
Use: "fileops",
Short: "List active file operations (move/copy status polling)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ops, err := c.ListFileOps(cmd.Context())
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(ops))
for _, op := range ops {
rows = append(rows, map[string]any{
"id": fmt.Sprint(op["id"]),
"operation": fmt.Sprint(op["operation"]),
"progress": fmt.Sprint(op["progress"]),
"finished": fmt.Sprint(op["finished"]),
"error": fmt.Sprint(op["error"]),
})
}
printTable([]string{"id", "operation", "progress", "finished", "error"}, rows)
return nil
},
}
}
+44 -70
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -423,44 +424,28 @@ func docsPutMDCmd() *cobra.Command {
} }
} }
ctx := cmd.Context() ctx := cmd.Context()
var ent *onlyoffice.FileEntry ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"folder_id": folderID,
"md": mdPath,
"uploaded": fileEntryToMap(ent),
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
}
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
if err != nil { if err != nil {
return err return err
} }
printObject(map[string]any{ obj := map[string]any{
"project_id": pid, "project_id": pid,
"md": mdPath, "md": mdPath,
"uploaded": fileEntryToMap(ent), "uploaded": fileEntryToMap(ent),
}) }
if folderID != "" {
obj["folder_id"] = folderID
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil return nil
}, },
} }
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)") cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path") cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)") cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
return cmd return cmd
} }
@@ -503,44 +488,28 @@ func docsPutTxtCmd() *cobra.Command {
} }
} }
ctx := cmd.Context() ctx := cmd.Context()
var ent *onlyoffice.FileEntry ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"txt": txtPath,
"folder_id": folderID,
"uploaded": fileEntryToMap(ent),
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
}
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
if err != nil { if err != nil {
return err return err
} }
printObject(map[string]any{ obj := map[string]any{
"project_id": pid, "project_id": pid,
"txt": txtPath, "txt": txtPath,
"uploaded": fileEntryToMap(ent), "uploaded": fileEntryToMap(ent),
}) }
if folderID != "" {
obj["folder_id"] = folderID
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil return nil
}, },
} }
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)") cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path") cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)") cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
return cmd return cmd
} }
@@ -618,21 +587,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
} }
ctx := cmd.Context() ctx := cmd.Context()
var ent *onlyoffice.FileEntry ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace)
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, xlsxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, xlsxPath)
}
} else {
if replace {
ent, deleted, err = c.UploadProjectFileReplacing(ctx, pid, xlsxPath)
} else {
ent, err = c.UploadProjectFile(ctx, pid, xlsxPath)
}
}
if err != nil { if err != nil {
return err return err
} }
@@ -655,6 +610,25 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)") cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template") cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path") cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files before upload") cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
return cmd return cmd
} }
// uploadProjectDoc upserts (--replace, default) or no-clobbers into project/folder Documents.
func uploadProjectDoc(ctx context.Context, c *onlyoffice.Client, pid, localPath, folderID string, replace bool) (*onlyoffice.FileEntry, []int, error) {
if folderID != "" {
if replace {
return c.UploadToFolderReplacing(ctx, folderID, localPath)
}
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
return nil, nil, err
}
ent, err := c.UploadToFolder(ctx, folderID, localPath)
return ent, nil, err
}
if replace {
return c.UploadProjectFileReplacing(ctx, pid, localPath)
}
ent, err := c.UploadProjectFileNoClobber(ctx, pid, localPath)
return ent, nil, err
}
+1
View File
@@ -16,6 +16,7 @@
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
// oo docs tools | convert | ocr | as-md | put-md // oo docs tools | convert | ocr | as-md | put-md
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
// //
// CRM association rules: docs/crm-associations.md // CRM association rules: docs/crm-associations.md
// //
+24 -5
View File
@@ -107,10 +107,13 @@ func prjFilesListCmd() *cobra.Command {
} }
func prjFilesUploadCmd() *cobra.Command { func prjFilesUploadCmd() *cobra.Command {
return &cobra.Command{ var replace, allowDuplicate bool
cmd := &cobra.Command{
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]", Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload file(s) into the project's Documents folder", Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
Args: cobra.MinimumNArgs(2), Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd) c, err := newOO(cmd)
if err != nil { if err != nil {
@@ -118,15 +121,31 @@ func prjFilesUploadCmd() *cobra.Command {
} }
pid := args[0] pid := args[0]
for _, p := range args[1:] { for _, p := range args[1:] {
entry, err := c.UploadProjectFile(cmd.Context(), pid, p) var entry *onlyoffice.FileEntry
var deleted []int
switch {
case allowDuplicate:
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
case replace:
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
default:
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
}
if err != nil { if err != nil {
return err return err
} }
printObject(fileEntryToMap(entry)) obj := fileEntryToMap(entry)
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
} }
return nil return nil
}, },
} }
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
return cmd
} }
func prjFilesDownloadCmd() *cobra.Command { func prjFilesDownloadCmd() *cobra.Command {
+29
View File
@@ -15,10 +15,15 @@ import (
) )
// ListContacts returns a page of CRM contacts and the total count. // ListContacts returns a page of CRM contacts and the total count.
// sortBy=id is always set: OnlyOffice filter.json without an explicit sort
// order is non-deterministic on large contact sets, so a paged walk
// (ListAllContacts, ListContactsByTag, FindCompany, FindPerson) can skip or
// duplicate contacts across page boundaries.
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) { func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
q.Set("count", strconv.Itoa(count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", strconv.Itoa(startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
q.Set("sortBy", "id")
if search != "" { if search != "" {
q.Set("filterValue", search) q.Set("filterValue", search)
} }
@@ -243,6 +248,24 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
} }
// UpdateContactName renames a CRM company contact displayName.
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
body := map[string]any{
"displayName": newName,
"companyName": newName,
"isCompany": true,
}
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
if err != nil {
return out, err
}
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
out = fresh
}
return out, nil
}
// ListContactTags returns all CRM contact tags (title + relativeItemsCount). // ListContactTags returns all CRM contact tags (title + relativeItemsCount).
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) { func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json") return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
@@ -288,6 +311,7 @@ func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, s
q.Set("count", strconv.Itoa(count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", strconv.Itoa(startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
q.Set("tags", tagName) q.Set("tags", tagName)
q.Set("sortBy", "id")
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode()) raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -717,6 +741,11 @@ func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any,
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
} }
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
}
// ListTaskCategories returns CRM task categories. // ListTaskCategories returns CRM task categories.
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) { func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json") return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
+28
View File
@@ -321,11 +321,21 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
"folderIds": []int{}, "folderIds": []int{},
"fileIds": fileIDs, "fileIds": fileIDs,
"destFolderId": destFolderID, "destFolderId": destFolderID,
"resolveType": "Skip",
"holdResult": true,
} }
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body) out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
if err != nil { if err != nil {
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body) out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
} }
if err != nil {
return nil, err
}
if raw, merr := json.Marshal(out); merr == nil {
if ferr := fileopsError(raw); ferr != nil {
return nil, ferr
}
}
return out, err return out, err
} }
@@ -346,6 +356,24 @@ func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string)
return decodeResponseFileEntry(raw) return decodeResponseFileEntry(raw)
} }
// UpdateFile uploads a new version of an existing file (same id, name and
// folder). It does not delete and does not create a second file.
func (c *Client) UpdateFile(ctx context.Context, fileID, localPath string) (*FileEntry, error) {
if fileID == "" || localPath == "" {
return nil, fmt.Errorf("file id and local path are required")
}
uploadPath := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID))
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
uploadPath = fmt.Sprintf("/api/2.0/files/%s/update.json", url.PathEscape(fileID))
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
return nil, err
}
}
return decodeResponseFileEntry(raw)
}
// FileFolderID returns the parent folder id string for a file entry, if known. // FileFolderID returns the parent folder id string for a file entry, if known.
func FileFolderID(f *FileEntry) string { func FileFolderID(f *FileEntry) string {
if f == nil || f.FolderID == nil { if f == nil || f.FolderID == nil {
+52 -6
View File
@@ -2,6 +2,7 @@ package onlyoffice
import ( import (
"context" "context"
"encoding/json"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -310,25 +311,70 @@ func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext
return ids, nil return ids, nil
} }
// mergeProjectRootForDedupe includes projectFolder files in dedupe scans. OO often lists
// root documents only in pf.Files while pf.Folders is empty.
func mergeProjectRootForDedupe(rootID string, folders []*FolderEntry, filesByFolder map[string][]*FileEntry, rootFiles []*FileEntry) ([]*FolderEntry, map[string][]*FileEntry) {
if rootID == "" {
return folders, filesByFolder
}
if filesByFolder == nil {
filesByFolder = map[string][]*FileEntry{}
}
for _, folder := range folders {
if folder != nil && folder.ID != nil && folder.ID.String() == rootID {
if len(rootFiles) > 0 {
filesByFolder[rootID] = rootFiles
}
return folders, filesByFolder
}
}
if len(rootFiles) == 0 {
return folders, filesByFolder
}
id := json.Number(rootID)
title := "(project root)"
folders = append(folders, &FolderEntry{ID: &id, Title: &title})
filesByFolder[rootID] = rootFiles
return folders, filesByFolder
}
// DedupeProject scans project folders and optionally deletes duplicates. // DedupeProject scans project folders and optionally deletes duplicates.
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) { func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
pf, err := c.GetProjectFiles(ctx, projectID) pf, err := c.GetProjectFiles(ctx, projectID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders)) rootID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, nil, err
}
var rootFiles []*FileEntry
if rootID != "" {
rootFiles, err = c.FolderFiles(ctx, rootID)
if err != nil {
return nil, nil, err
}
}
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders)+1)
folders := make([]*FolderEntry, 0, len(pf.Folders)+1)
for _, folder := range pf.Folders { for _, folder := range pf.Folders {
if folder == nil || folder.ID == nil { if folder == nil || folder.ID == nil {
continue continue
} }
fid := folder.ID.String() fid := folder.ID.String()
files, err := c.FolderFiles(ctx, fid) if fid == rootID {
if err != nil { filesByFolder[fid] = rootFiles
return nil, nil, err } else {
files, err := c.FolderFiles(ctx, fid)
if err != nil {
return nil, nil, err
}
filesByFolder[fid] = files
} }
filesByFolder[fid] = files folders = append(folders, folder)
} }
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts) folders, filesByFolder = mergeProjectRootForDedupe(rootID, folders, filesByFolder, rootFiles)
groups := FindProjectDuplicates(folders, filesByFolder, opts)
if !apply || len(groups) == 0 { if !apply || len(groups) == 0 {
return groups, nil, nil return groups, nil, nil
} }
+20
View File
@@ -64,6 +64,26 @@ func TestCrossFolderPrefersNonTrash(t *testing.T) {
} }
} }
func TestMergeProjectRootForDedupe(t *testing.T) {
old := &FileEntry{ID: jsonNum("1"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
newer := &FileEntry{ID: jsonNum("2"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
rootFiles := []*FileEntry{old, newer}
folders, byFolder := mergeProjectRootForDedupe("489", nil, nil, rootFiles)
if len(folders) != 1 || folders[0].ID.String() != "489" {
t.Fatalf("folders=%+v", folders)
}
if len(byFolder["489"]) != 2 {
t.Fatalf("root files=%d", len(byFolder["489"]))
}
groups := findWithinFolderDuplicates([]ProjectFolderFile{
{FolderID: "489", FolderTitle: "(project root)", File: old},
{FolderID: "489", FolderTitle: "(project root)", File: newer},
})
if len(groups) != 1 {
t.Fatalf("groups=%d", len(groups))
}
}
func TestIsTrashFolderTitle(t *testing.T) { func TestIsTrashFolderTitle(t *testing.T) {
if !IsTrashFolderTitle("_trash-md") { if !IsTrashFolderTitle("_trash-md") {
t.Fatal("expected trash") t.Fatal("expected trash")
+37
View File
@@ -3,10 +3,15 @@ package onlyoffice
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
) )
// ErrFileExists is returned when --no-replace / no-clobber upload hits an existing stem|ext.
var ErrFileExists = errors.New("onlyoffice: file already exists in folder (use replace or delete first)")
// FileEntryStem returns the logical basename without duplicated extensions. // FileEntryStem returns the logical basename without duplicated extensions.
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx). // OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
func FileEntryStem(f *FileEntry) string { func FileEntryStem(f *FileEntry) string {
@@ -116,6 +121,38 @@ func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) (
return ids, nil return ids, nil
} }
// AssertNoFileConflict reports ErrFileExists when localPath stem|ext is already in folderID.
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
files, err := c.FolderFiles(ctx, folderID)
if err != nil {
return err
}
stem := UploadStemFromLocal(localPath)
ext := UploadExtFromLocal(localPath)
matches := FindFilesByDedupKey(files, stem, ext)
if len(matches) == 0 {
return nil
}
ids := make([]string, 0, len(matches))
for _, f := range matches {
ids = append(ids, fmt.Sprintf("%d", FileEntryNumericID(f)))
}
return fmt.Errorf("%w: %s%s in folder %s (existing file ids: %s)",
ErrFileExists, stem, ext, folderID, strings.Join(ids, ", "))
}
// UploadProjectFileNoClobber uploads only when stem|ext is not already in the project folder.
func (c *Client) UploadProjectFileNoClobber(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
folderID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, err
}
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
return nil, err
}
return c.UploadProjectFile(ctx, projectID, localPath)
}
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath. // UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) { func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
stem := UploadStemFromLocal(localPath) stem := UploadStemFromLocal(localPath)
+62 -4
View File
@@ -144,27 +144,85 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
} }
// MoveDavItems moves the given folders and/or files into destFolderID. // MoveDavItems moves the given folders and/or files into destFolderID.
// The fileops API answers 200 with per-operation error strings even when
// nothing moves (e.g. missing permission), so the response is parsed and the
// first operation error is returned instead of a silent nil.
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
"folderIds": nums(folderIDs), "folderIds": nums(folderIDs),
"fileIds": nums(fileIDs), "fileIds": nums(fileIDs),
"destFolderId": num(destFolderID), "destFolderId": num(destFolderID),
"resolveType": "Skip", "resolveType": "Skip",
"holdResult": true, "holdResult": true,
}) })
return err if err != nil {
return err
}
return fileopsError(raw)
} }
// CopyDavItems copies the given folders and/or files into destFolderID. // CopyDavItems copies the given folders and/or files into destFolderID.
// Per-operation errors are surfaced like in MoveDavItems.
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
"folderIds": nums(folderIDs), "folderIds": nums(folderIDs),
"fileIds": nums(fileIDs), "fileIds": nums(fileIDs),
"destFolderId": num(destFolderID), "destFolderId": num(destFolderID),
"conflictResolveType": "Skip", "conflictResolveType": "Skip",
"deleteAfter": true, "deleteAfter": true,
}) })
return err if err != nil {
return err
}
return fileopsError(raw)
}
// ListFileOps returns the currently active file operations
// (GET /api/2.0/files/fileops) for status polling.
func (c *Client) ListFileOps(ctx context.Context) ([]map[string]any, error) {
raw, err := c.getJSON(ctx, "/api/2.0/files/fileops")
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
var ops []map[string]any
if err := json.Unmarshal(resp, &ops); err != nil {
return nil, err
}
return ops, nil
}
// fileopsError extracts per-operation "error" strings from a fileops/move or
// fileops/copy envelope. A 200 with error entries means nothing moved.
func fileopsError(raw json.RawMessage) error {
resp, err := responseField(raw, "response")
if err != nil {
return err
}
var ops []struct {
Error *string `json:"error"`
Finished *bool `json:"finished"`
Progress *int `json:"progress"`
}
if err := json.Unmarshal(resp, &ops); err != nil {
return nil // not an operations envelope — nothing to report
}
var errs []string
for _, op := range ops {
if op.Error != nil && *op.Error != "" {
errs = append(errs, *op.Error)
}
}
if len(errs) > 0 {
return fmt.Errorf("onlyoffice: fileops: %s", strings.Join(errs, "; "))
}
return nil
} }
// DeleteDavItems deletes the given folders and/or files. // DeleteDavItems deletes the given folders and/or files.
+175
View File
@@ -0,0 +1,175 @@
package onlyoffice
// High-level mail folder walk for ETL consumers (2dph brain mail-ingest,
// cv tools). This is the "integration layer" half of reusing the canonical
// client instead of private per-project OOClient copies: the caller gets a
// single hydrated stream instead of hand-rolling list → get → download
// against the raw API.
import (
"context"
"fmt"
"strconv"
"time"
)
// MailSyncAttachment is one attachment of a hydrated mail message.
type MailSyncAttachment struct {
ID string // id accepted by Client.DownloadMailAttachment
Name string
Size int64
Body []byte // non-nil only when MailSyncOptions.FetchBodies is set
}
// MailSyncMessage is a hydrated mail message for sync pipelines.
type MailSyncMessage struct {
ID int64
Folder int
Subject string
From string // raw RFC 5322 header value ("Name" <addr>)
Date time.Time
IsNew bool
HasAttachments bool
Attachments []MailSyncAttachment
}
// MailSyncOptions controls FetchMailFolder.
type MailSyncOptions struct {
Limit int // max messages to hydrate; 0 = whole folder
StartIndex int // skip this many messages before collecting
FetchBodies bool // eagerly download attachment bytes
}
// FetchMailFolder walks a mail folder page by page and hydrates every
// message: list → get → (optionally) download attachments. It is the single
// entry point sync pipelines need on top of the mail API.
//
// Messages are returned in API order (newest first). The folder walk stops
// at the first empty or short page.
func (c *Client) FetchMailFolder(ctx context.Context, folderID int, opts MailSyncOptions) ([]MailSyncMessage, error) {
if folderID <= 0 {
folderID = MailFolderInbox
}
var out []MailSyncMessage
skipped := 0
for page := 1; ; page++ {
batch, err := c.ResponseArray(ctx,
mailMessagesPath(MailMessagesFilter{Folder: folderID}, page, mailMessagesPageSize))
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: %w", err)
}
if len(batch) == 0 {
break
}
for _, raw := range batch {
if skipped < opts.StartIndex {
skipped++
continue
}
msg, err := c.hydrateMailMessage(ctx, raw, opts)
if err != nil {
return nil, err
}
out = append(out, *msg)
if opts.Limit > 0 && len(out) >= opts.Limit {
return out, nil
}
}
if len(batch) < mailMessagesPageSize {
break
}
}
return out, nil
}
// hydrateMailMessage converts one raw API message into a MailSyncMessage,
// fetching the full record when the list item does not carry the attachment
// metadata, and downloading bodies when requested.
func (c *Client) hydrateMailMessage(ctx context.Context, m map[string]any, opts MailSyncOptions) (*MailSyncMessage, error) {
msg := &MailSyncMessage{
ID: Int64FromMap(m, "id"),
Folder: int(Int64FromMap(m, "folder")),
Subject: stringFromMap(m, "subject"),
From: stringFromMap(m, "from"),
IsNew: boolFromMap(m, "isNew") == "true",
}
msg.Date = parseMailTime(stringFromMap(m, "date"))
atts, _ := m["attachments"].([]any)
hasFlag := boolFromMap(m, "hasAttachments") == "true"
if hasFlag && len(atts) == 0 {
// List items may omit the attachment array; pull the full record.
full, err := c.GetMailMessage(ctx, strconv.FormatInt(msg.ID, 10))
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: hydrate message %d: %w", msg.ID, err)
}
atts, _ = full["attachments"].([]any)
}
for _, a := range atts {
am, ok := a.(map[string]any)
if !ok {
continue
}
att := MailSyncAttachment{
ID: mailAttachmentID(am),
Name: stringFromMap(am, "fileName"),
Size: Int64FromMap(am, "size"),
}
if att.Name == "" {
att.Name = stringFromMap(am, "name")
}
if att.ID != "" {
msg.Attachments = append(msg.Attachments, att)
}
}
msg.HasAttachments = hasFlag || len(msg.Attachments) > 0
if opts.FetchBodies {
for i := range msg.Attachments {
body, err := c.DownloadMailAttachment(ctx, msg.Attachments[i].ID)
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: message %d attachment %q: %w",
msg.ID, msg.Attachments[i].Name, err)
}
msg.Attachments[i].Body = body
}
}
return msg, nil
}
// mailAttachmentID extracts the download id from an attachment object.
// OnlyOffice variants use "id", "fileId" or "attachmentId".
func mailAttachmentID(am map[string]any) string {
for _, key := range []string{"id", "fileId", "attachmentId"} {
switch v := am[key].(type) {
case string:
if s := v; s != "" {
return s
}
case float64:
if n := int64(v); n != 0 {
return strconv.FormatInt(n, 10)
}
case int64:
if v != 0 {
return strconv.FormatInt(v, 10)
}
}
}
return ""
}
// parseMailTime accepts the OnlyOffice timestamp shapes seen in the wild:
// RFC3339 (with any fractional digits) and second-precision local form.
func parseMailTime(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05", s); err == nil {
return t
}
return time.Time{}
}
+137
View File
@@ -0,0 +1,137 @@
package onlyoffice
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mailsSyncMock serves a two-page inbox: page 1 has two list items (one
// reporting hasAttachments but omitting the attachment array, forcing the
// full-record fetch), page 2 is empty. The full record for message 102
// carries one attachment whose body is served by download.ashx.
func newMailSyncTestServer(t *testing.T, msgsPage1 string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/2.0/authentication.json":
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc", Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
case r.URL.Path == "/api/2.0/mail/messages":
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("page") > "1" {
_, _ = w.Write([]byte(`{"response":[]}`))
return
}
_, _ = w.Write([]byte(`{"response":[` + msgsPage1 + `]}`))
case r.URL.Path == "/api/2.0/mail/messages/102":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{
"id":102,"subject":"Full record","from":"\"A\" <a@b.com>",
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
"hasAttachments":true,
"attachments":[{"id":77,"fileName":"report.pdf","size":3}]}}`))
case r.URL.Path == "/addons/mail/httphandlers/download.ashx":
if r.Header.Get("Cookie") == "" {
http.Error(w, "missing cookie", http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("PDF!"))
default:
http.NotFound(w, r)
}
}))
}
func TestFetchMailFolderHydratesAndDownloads(t *testing.T) {
page1 := `
{"id":101,"subject":"Plain","from":"x@y.z","date":"2026-08-21T09:00:00Z",
"folder":1,"isNew":true,"hasAttachments":false},
{"id":102,"subject":"With attachment (list item)","from":"a@b.com",
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
"hasAttachments":true}
`
srv := newMailSyncTestServer(t, page1)
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
msgs, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{FetchBodies: true})
if err != nil {
t.Fatalf("FetchMailFolder: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2", len(msgs))
}
first := msgs[0]
if first.ID != 101 || first.Subject != "Plain" || !first.IsNew {
t.Fatalf("first = %+v", first)
}
if first.Date.IsZero() || first.Date.Year() != 2026 {
t.Fatalf("first date = %v", first.Date)
}
if first.HasAttachments {
t.Fatalf("first should have no attachments")
}
second := msgs[1]
if !second.HasAttachments || len(second.Attachments) != 1 {
t.Fatalf("second attachments = %+v", second.Attachments)
}
att := second.Attachments[0]
if att.ID != "77" || att.Name != "report.pdf" || att.Size != 3 || string(att.Body) != "PDF!" {
t.Fatalf("attachment = %+v", att)
}
if second.Date.Location() == time.UTC && second.Date.Hour() != 8 {
t.Fatalf("second date = %v (want +02:00 offset preserved)", second.Date)
}
}
func TestFetchMailFolderLimitAndStartIndex(t *testing.T) {
var items []string
for i := 1; i <= 5; i++ {
items = append(items, `{"id":`+string(rune('0'+i))+`,"subject":"m`+string(rune('0'+i))+`",
"from":"x@y.z","date":"2026-08-20T00:00:00Z","folder":1}`)
}
srv := newMailSyncTestServer(t, strings.Join(items, ","))
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{StartIndex: 1, Limit: 2})
if err != nil {
t.Fatalf("FetchMailFolder: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d messages, want 2", len(got))
}
if got[0].ID != 2 || got[1].ID != 3 {
t.Fatalf("ids = %d,%d want 2,3", got[0].ID, got[1].ID)
}
}
func TestParseMailTime(t *testing.T) {
fractions := "2026-08-22T10:15:00.1234567+02:00"
if parseMailTime(fractions).IsZero() {
t.Fatalf("RFC3339 with 7-digit fraction failed: %q", fractions)
}
if parseMailTime("2026-08-22T10:15:00").IsZero() {
t.Fatal("second-precision form failed")
}
if !parseMailTime("").IsZero() || !parseMailTime("garbage").IsZero() {
t.Fatal("unparseable input must yield zero time")
}
}