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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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`. 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.
|
- 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`.
|
||||||
|
|||||||
+16
-4
@@ -343,6 +343,7 @@ func docsAsMDCmd() *cobra.Command {
|
|||||||
func docsPutMDCmd() *cobra.Command {
|
func docsPutMDCmd() *cobra.Command {
|
||||||
var folderID string
|
var folderID string
|
||||||
var keepLocalDOCX string
|
var keepLocalDOCX string
|
||||||
|
var replace bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "put-md PROJECT_ID MARKDOWN_PATH",
|
Use: "put-md PROJECT_ID MARKDOWN_PATH",
|
||||||
Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)",
|
Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)",
|
||||||
@@ -378,20 +379,30 @@ func docsPutMDCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
|
var ent *onlyoffice.FileEntry
|
||||||
|
var deleted []int
|
||||||
if folderID != "" {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"folder_id": folderID,
|
"folder_id": folderID,
|
||||||
"md": mdPath,
|
"md": mdPath,
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
})
|
}
|
||||||
|
if len(deleted) > 0 {
|
||||||
|
obj["replaced_file_ids"] = deleted
|
||||||
|
}
|
||||||
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ent, err := c.UploadProjectFile(ctx, pid, docxPath)
|
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -405,5 +416,6 @@ func docsPutMDCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
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)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
+127
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user