From 50bd47570b6430cebde43641a7b1d2f8d54d32f2 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 27 Aug 2026 23:28:11 +0100 Subject: [PATCH] fix(docs): put-md upsert by stem to avoid duplicate folder files UploadToFolderReplacing deletes same-stem files before upload (--replace, default true). OO may still display title+fileExst as double extension. Co-authored-by: Cursor --- AGENTS.md | 2 +- cmd/oo/docs.go | 20 +++++-- files_stem.go | 127 +++++++++++++++++++++++++++++++++++++++++++++ files_stem_test.go | 35 +++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 files_stem.go create mode 100644 files_stem_test.go diff --git a/AGENTS.md b/AGENTS.md index d6f8f23..39ba441 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`. Use `oo docs put-md` (md→docx upload) and `oo docs as-md` (download→OCR if needed→markdown). Local converters live in `internal/docpipe` (pandoc / ocrmypdf / pdftotext / tesseract+go-hocr). For layout-aware extracts: `oo docs hocr` (or `as-md --hocr`). +- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`. Use `oo docs put-md` (md→docx upload, `--replace` default upserts by stem) and `oo docs as-md` (download→OCR if needed→markdown). Local converters live in `internal/docpipe` (pandoc / ocrmypdf / pdftotext / tesseract+go-hocr). For layout-aware extracts: `oo docs hocr` (or `as-md --hocr`). OO UI may show `title`+`fileExst` as double extension (e.g. `.docx.docx`) — cosmetic server quirk. - 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 8612976..78d8b1c 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -343,6 +343,7 @@ func docsAsMDCmd() *cobra.Command { func docsPutMDCmd() *cobra.Command { var folderID string var keepLocalDOCX string + var replace bool cmd := &cobra.Command{ Use: "put-md PROJECT_ID MARKDOWN_PATH", Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)", @@ -378,20 +379,30 @@ func docsPutMDCmd() *cobra.Command { } } ctx := cmd.Context() + var ent *onlyoffice.FileEntry + var deleted []int if folderID != "" { - ent, err := c.UploadToFolder(ctx, folderID, docxPath) + if replace { + ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath) + } else { + ent, err = c.UploadToFolder(ctx, folderID, docxPath) + } if err != nil { return err } - printObject(map[string]any{ + 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, err = c.UploadProjectFile(ctx, pid, docxPath) if err != nil { return err } @@ -405,5 +416,6 @@ func docsPutMDCmd() *cobra.Command { } 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)") return cmd } diff --git a/files_stem.go b/files_stem.go new file mode 100644 index 0000000..18eac89 --- /dev/null +++ b/files_stem.go @@ -0,0 +1,127 @@ +package onlyoffice + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" +) + +// 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 { + if f == nil || f.Title == nil { + return "" + } + exst := "" + if f.FileExst != nil { + exst = *f.FileExst + } + return NormalizeUploadStem(*f.Title, exst) +} + +// NormalizeUploadStem derives a stable stem for matching uploads. +func NormalizeUploadStem(title, exst string) string { + t := strings.TrimSpace(title) + t = strings.TrimSuffix(t, ".") + if exst != "" && strings.HasSuffix(t, exst) { + t = strings.TrimSuffix(t, exst) + } + if ext := filepath.Ext(t); ext != "" { + t = strings.TrimSuffix(t, ext) + } + return strings.TrimSpace(t) +} + +// UploadStemFromLocal returns the stem used to match/replace folder files. +func UploadStemFromLocal(localPath string) string { + base := filepath.Base(localPath) + ext := filepath.Ext(base) + if ext != "" { + base = strings.TrimSuffix(base, ext) + } + return base +} + +// FolderFiles returns file entries in a Documents folder. +func (c *Client) FolderFiles(ctx context.Context, folderID string) ([]*FileEntry, error) { + raw, err := c.ListFolder(ctx, folderID) + if err != nil { + return nil, err + } + return ParseFolderFileEntries(raw), nil +} + +// ParseFolderFileEntries extracts []*FileEntry from ListFolder JSON. +func ParseFolderFileEntries(raw map[string]any) []*FileEntry { + items, _ := raw["files"].([]any) + out := make([]*FileEntry, 0, len(items)) + for _, it := range items { + m, ok := it.(map[string]any) + if !ok { + continue + } + b, err := json.Marshal(m) + if err != nil { + continue + } + var f FileEntry + if err := json.Unmarshal(b, &f); err != nil { + continue + } + out = append(out, &f) + } + return out +} + +// FindFilesByStem returns folder files whose logical stem matches. +func FindFilesByStem(files []*FileEntry, stem string) []*FileEntry { + stem = strings.TrimSpace(stem) + if stem == "" { + return nil + } + var out []*FileEntry + for _, f := range files { + if FileEntryStem(f) == stem { + out = append(out, f) + } + } + return out +} + +// DeleteFilesByStem removes all files in folderID matching stem (for put-md upsert). +func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) ([]int, error) { + files, err := c.FolderFiles(ctx, folderID) + if err != nil { + return nil, err + } + matches := FindFilesByStem(files, stem) + if len(matches) == 0 { + return nil, nil + } + ids := make([]int, 0, len(matches)) + for _, f := range matches { + n := int(FileEntryNumericID(f)) + if n != 0 { + ids = append(ids, n) + } + } + if len(ids) == 0 { + return nil, nil + } + if err := c.DeleteFiles(ctx, ids); err != nil { + return nil, err + } + return ids, nil +} + +// UploadToFolderReplacing deletes same-stem files then uploads localPath. +func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) { + stem := UploadStemFromLocal(localPath) + deleted, err := c.DeleteFilesByStem(ctx, folderID, stem) + if err != nil { + return nil, deleted, err + } + ent, err := c.UploadToFolder(ctx, folderID, localPath) + return ent, deleted, err +} diff --git a/files_stem_test.go b/files_stem_test.go new file mode 100644 index 0000000..cbab350 --- /dev/null +++ b/files_stem_test.go @@ -0,0 +1,35 @@ +package onlyoffice + +import "testing" + +func TestNormalizeUploadStem(t *testing.T) { + tests := []struct { + title, exst, want string + }{ + {"OO-HONDA-7-INDEX.docx", ".docx", "OO-HONDA-7-INDEX"}, + {"README.txt", ".txt", "README"}, + {"car-docs-print.docx", ".docx", "car-docs-print"}, + {"plain.", "", "plain"}, + {"foo", ".docx", "foo"}, + } + for _, tc := range tests { + if got := NormalizeUploadStem(tc.title, tc.exst); got != tc.want { + t.Fatalf("NormalizeUploadStem(%q,%q)=%q want %q", tc.title, tc.exst, got, tc.want) + } + } +} + +func TestFileEntryStem(t *testing.T) { + title := "00-INDEX.docx" + exst := ".docx" + f := &FileEntry{Title: &title, FileExst: &exst} + if got := FileEntryStem(f); got != "00-INDEX" { + t.Fatalf("got %q", got) + } +} + +func TestUploadStemFromLocal(t *testing.T) { + if got := UploadStemFromLocal("/tmp/OO-HONDA-7-INDEX.docx"); got != "OO-HONDA-7-INDEX" { + t.Fatalf("got %q", got) + } +}