Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d | ||
|
|
35f0cb8d20 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.16.0"
|
".": "0.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+32
-58
@@ -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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"folder_id": folderID,
|
|
||||||
"md": mdPath,
|
"md": mdPath,
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
printObject(map[string]any{
|
|
||||||
"project_id": pid,
|
|
||||||
"md": mdPath,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
|
||||||
})
|
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"txt": txtPath,
|
"txt": txtPath,
|
||||||
"folder_id": folderID,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
printObject(map[string]any{
|
|
||||||
"project_id": pid,
|
|
||||||
"txt": txtPath,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
|
||||||
})
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,9 +107,12 @@ 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)",
|
||||||
|
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),
|
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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
+48
-2
@@ -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()
|
||||||
|
if fid == rootID {
|
||||||
|
filesByFolder[fid] = rootFiles
|
||||||
|
} else {
|
||||||
files, err := c.FolderFiles(ctx, fid)
|
files, err := c.FolderFiles(ctx, fid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
filesByFolder[fid] = files
|
filesByFolder[fid] = files
|
||||||
}
|
}
|
||||||
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts)
|
folders = append(folders, folder)
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+175
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user