diff --git a/AGENTS.md b/AGENTS.md index a9b9e31..489fd7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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 `), never `oo -`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/.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. - 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`. diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 35b4b6c..7e7ff31 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "path/filepath" @@ -423,44 +424,28 @@ func docsPutMDCmd() *cobra.Command { } } ctx := cmd.Context() - var ent *onlyoffice.FileEntry - 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) + ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace) if err != nil { return err } - printObject(map[string]any{ + obj := map[string]any{ "project_id": pid, "md": mdPath, "uploaded": fileEntryToMap(ent), - }) + } + if folderID != "" { + obj["folder_id"] = folderID + } + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) return nil }, } 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().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 } @@ -503,44 +488,28 @@ func docsPutTxtCmd() *cobra.Command { } } ctx := cmd.Context() - var ent *onlyoffice.FileEntry - 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) + ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace) if err != nil { return err } - printObject(map[string]any{ + obj := map[string]any{ "project_id": pid, "txt": txtPath, "uploaded": fileEntryToMap(ent), - }) + } + if folderID != "" { + obj["folder_id"] = folderID + } + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) return nil }, } 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().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 } @@ -618,21 +587,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an } ctx := cmd.Context() - var ent *onlyoffice.FileEntry - 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) - } - } + ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace) if err != nil { 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(&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().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 } + +// 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 +} diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index df726d1..0469d98 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -107,10 +107,13 @@ func prjFilesListCmd() *cobra.Command { } func prjFilesUploadCmd() *cobra.Command { - return &cobra.Command{ + var replace, allowDuplicate bool + cmd := &cobra.Command{ Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]", - Short: "Upload file(s) into the project's Documents folder", - Args: cobra.MinimumNArgs(2), + 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), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { @@ -118,15 +121,31 @@ func prjFilesUploadCmd() *cobra.Command { } pid := args[0] 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 { return err } - printObject(fileEntryToMap(entry)) + obj := fileEntryToMap(entry) + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) } 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 { diff --git a/files_dedupe.go b/files_dedupe.go index c0161a2..73e0b83 100644 --- a/files_dedupe.go +++ b/files_dedupe.go @@ -2,6 +2,7 @@ package onlyoffice import ( "context" + "encoding/json" "path/filepath" "sort" "strings" @@ -310,25 +311,70 @@ func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext 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. func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) { pf, err := c.GetProjectFiles(ctx, projectID) if err != nil { 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 { if folder == nil || folder.ID == nil { continue } fid := folder.ID.String() - files, err := c.FolderFiles(ctx, fid) - if err != nil { - return nil, nil, err + if fid == rootID { + filesByFolder[fid] = rootFiles + } 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 { return groups, nil, nil } diff --git a/files_dedupe_test.go b/files_dedupe_test.go index 87a51d1..6d24a85 100644 --- a/files_dedupe_test.go +++ b/files_dedupe_test.go @@ -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) { if !IsTrashFolderTitle("_trash-md") { t.Fatal("expected trash") diff --git a/files_stem.go b/files_stem.go index b9552a6..02b2b53 100644 --- a/files_stem.go +++ b/files_stem.go @@ -3,10 +3,15 @@ package onlyoffice import ( "context" "encoding/json" + "errors" + "fmt" "path/filepath" "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. // OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx). func FileEntryStem(f *FileEntry) string { @@ -116,6 +121,38 @@ func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) ( 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. func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) { stem := UploadStemFromLocal(localPath)