Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 6s
Tests / Test (Go stable) (pull_request) Successful in 30s
Tests / Test (Go 1.25) (pull_request) Successful in 47s
167 lines
4.8 KiB
Go
167 lines
4.8 KiB
Go
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":"<paragraph text>","text":"<comment>"}.
|
|
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: <title> - 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"
|
|
}
|