feat(docs): oo docs comment — add Word comments to a .docx (#88)
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 5s
Tests / Test (Go stable) (pull_request) Successful in 2m51s
Tests / Test (Go 1.25) (pull_request) Successful in 2m51s
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 5s
Tests / Test (Go stable) (pull_request) Successful in 2m51s
Tests / Test (Go 1.25) (pull_request) Successful in 2m51s
This commit is contained in:
@@ -48,6 +48,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
|
||||
cmd.AddCommand(docsPutTxtCmd())
|
||||
cmd.AddCommand(docsPutXlsxCmd())
|
||||
cmd.AddCommand(docsToolsCmd())
|
||||
cmd.AddCommand(docsCommentCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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 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 buf bytes.Buffer
|
||||
if _, err := c.DownloadFile(ctx, args[0], &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if author == "" {
|
||||
author = selfDisplayName(cmd)
|
||||
}
|
||||
annotated, applied, err := onlyoffice.InjectDocxComments(buf.Bytes(), 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().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"
|
||||
}
|
||||
Reference in New Issue
Block a user