package main import ( "bytes" "fmt" "os" "path/filepath" "strings" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" ) func docsCommentCmd() *cobra.Command { var spec, author, out, basePath string var replace bool cmd := &cobra.Command{ Use: "comment FILE_ID", Short: "Add Word comments to an OO .docx, anchored to paragraph text", Long: `Adds real Word (DOCX) comments to a Documents file and, with --replace, uploads the annotated copy back over the same file id. OnlyOffice Workspace exposes no comments REST route (GET/POST /api/2.0/files/file/{id}/comment answer 404/500), while ONLYOFFICE Docs renders DOCX comments natively, so the comments are injected into the file itself. The spec is a JSON array of {"anchor":"","text":""}. An anchor is matched against the paragraph's concatenated text, so it survives text split across runs. At most one comment lands on a paragraph. oo docs comment 4379 --spec comments.json --out review.docx oo docs comment 4379 --spec comments.json --replace`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if spec == "" { return fmt.Errorf("--spec is required") } raw, err := os.ReadFile(spec) if err != nil { return err } comments, err := onlyoffice.ParseDocxComments(raw) if err != nil { return err } if len(comments) == 0 { return fmt.Errorf("spec %s has no comments", spec) } c, err := newOO(cmd) if err != nil { return err } ctx := cmd.Context() meta, err := c.GetFile(ctx, args[0]) if err != nil { return err } title := onlyoffice.FileEntryTitle(meta) if title == "" { title = "file-" + args[0] + ".docx" } if !strings.HasSuffix(strings.ToLower(title), ".docx") { return fmt.Errorf("%s is not a .docx (comments need Word format)", title) } var base []byte if basePath != "" { base, err = os.ReadFile(basePath) if err != nil { return err } } else { var buf bytes.Buffer if _, err := c.DownloadFile(ctx, args[0], &buf); err != nil { return err } base = buf.Bytes() } if author == "" { author = selfDisplayName(cmd) } annotated, applied, err := onlyoffice.InjectDocxComments(base, author, comments) if err != nil { return err } if applied == 0 { return fmt.Errorf("no anchor matched; check the spec against the document text") } dest := out if dest == "" { base := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title))) dest = base + " - comments.docx" } if err := os.WriteFile(dest, annotated, 0o600); err != nil { return err } obj := map[string]any{ "file_id": args[0], "title": title, "comments": len(comments), "applied": applied, "author": author, "local": dest, "replaced": false, "onlyoffice_link": c.FileEditorURL(args[0]), } if replace { tmp, err := os.MkdirTemp("", "oo-docs-comment-*") if err != nil { return err } defer os.RemoveAll(tmp) up := filepath.Join(tmp, onlyoffice.SafeLocalFileName(title)) if err := os.WriteFile(up, annotated, 0o600); err != nil { return err } // UpdateFile replaces the content in place, keeping the file id // (so existing links and the CRM entry stay valid). if _, err := c.UpdateFile(ctx, args[0], up); err != nil { return err } obj["replaced"] = true obj["folder_id"] = onlyoffice.FileFolderID(meta) } printJSON(obj) return nil }, } cmd.Flags().StringVar(&spec, "spec", "", "JSON file: [{\"anchor\":...,\"text\":...}] (required)") cmd.Flags().StringVar(&author, "author", "", "comment author (default: the authenticated portal user)") cmd.Flags().StringVar(&out, "out", "", "local output path (default: - comments.docx)") cmd.Flags().StringVar(&basePath, "base", "", "local clean .docx to annotate instead of downloading FILE_ID (re-apply without stacking comments)") cmd.Flags().BoolVar(&replace, "replace", false, "upload the annotated copy back over the same file id") return cmd } // selfDisplayName resolves the authenticated user's display name for the // comment author, falling back to first and last name, then to "OnlyOffice". func selfDisplayName(cmd *cobra.Command) string { c, err := newOO(cmd) if err != nil { return "OnlyOffice" } ctx := cmd.Context() id, err := c.SelfUserID(ctx) if err != nil || id == "" { return "OnlyOffice" } u, err := c.GetUser(ctx, id) if err != nil { return "OnlyOffice" } if s, ok := u["displayName"].(string); ok && strings.TrimSpace(s) != "" { return s } first, _ := u["firstName"].(string) last, _ := u["lastName"].(string) if name := strings.TrimSpace(first + " " + last); name != "" { return name } return "OnlyOffice" }