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

This commit is contained in:
2026-10-07 18:31:51 +01:00
parent 13a4b9f846
commit 5414cc2d37
4 changed files with 547 additions and 0 deletions
+1
View File
@@ -48,6 +48,7 @@ Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
cmd.AddCommand(docsPutTxtCmd()) cmd.AddCommand(docsPutTxtCmd())
cmd.AddCommand(docsPutXlsxCmd()) cmd.AddCommand(docsPutXlsxCmd())
cmd.AddCommand(docsToolsCmd()) cmd.AddCommand(docsToolsCmd())
cmd.AddCommand(docsCommentCmd())
return cmd return cmd
} }
+156
View File
@@ -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"
}
+252
View File
@@ -0,0 +1,252 @@
package onlyoffice
import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"regexp"
"strings"
)
// DocxComment is one Word comment. Anchor is matched against the plain text of
// a paragraph (entity-decoded, runs concatenated), so it survives text split
// across runs. At most one comment is placed per paragraph.
type DocxComment struct {
Anchor string `json:"anchor"`
Text string `json:"text"`
}
var (
docxParaRe = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
docxPPrRe = regexp.MustCompile(`(?s)^<w:p\b[^>]*>\s*<w:pPr>.*?</w:pPr>`)
docxOpenRe = regexp.MustCompile(`^<w:p\b[^>]*>`)
docxTextRe = regexp.MustCompile(`(?s)<w:t\b[^>]*>(.*?)</w:t>`)
)
// ParseDocxComments decodes a JSON array of comments.
func ParseDocxComments(data []byte) ([]DocxComment, error) {
var comments []DocxComment
if err := json.Unmarshal(data, &comments); err != nil {
return nil, fmt.Errorf("decode comments: %w", err)
}
return comments, nil
}
// DocxParagraphs returns the concatenated text of every paragraph in a .docx
// document.xml-free input (it takes the raw document.xml bytes).
func DocxParagraphs(documentXML []byte) []string {
out := make([]string, 0, 64)
for _, p := range docxParaRe.FindAllString(string(documentXML), -1) {
if txt := strings.TrimSpace(docxPlain(p)); txt != "" {
out = append(out, txt)
}
}
return out
}
// InjectDocxComments returns a copy of the .docx with real Word comments added,
// each anchored to the first paragraph whose text contains the comment's Anchor.
// The author name is written into every comment. It reports how many comments
// were applied; anchors that match nothing are skipped.
func InjectDocxComments(src []byte, author string, comments []DocxComment) ([]byte, int, error) {
parts, order, err := readDocxZip(src)
if err != nil {
return nil, 0, err
}
doc, ok := parts["word/document.xml"]
if !ok {
return nil, 0, fmt.Errorf("not a docx: word/document.xml missing")
}
if author == "" {
author = "Comment"
}
used := make([]bool, len(comments))
id := 0
newDoc := docxParaRe.ReplaceAllStringFunc(string(doc), func(p string) string {
txt := docxPlain(p)
for i, c := range comments {
if used[i] || c.Anchor == "" || !strings.Contains(txt, c.Anchor) {
continue
}
used[i] = true
p = docxAddMarkers(p, id)
id++
break
}
return p
})
applied := make([]DocxComment, 0, len(comments))
for i, c := range comments {
if used[i] {
applied = append(applied, c)
}
}
parts["word/document.xml"] = []byte(newDoc)
parts["word/comments.xml"] = docxBuildComments(author, applied)
parts["[Content_Types].xml"] = docxAddContentType(parts["[Content_Types].xml"])
if rels, ok := parts["word/_rels/document.xml.rels"]; ok {
parts["word/_rels/document.xml.rels"] = docxAddRel(rels)
} else {
parts["word/_rels/document.xml.rels"] = docxDefaultRels()
order = append(order, "word/_rels/document.xml.rels")
}
if !containsStr(order, "word/comments.xml") {
order = append(order, "word/comments.xml")
}
out, err := writeDocxZip(parts, order)
if err != nil {
return nil, 0, err
}
return out, len(applied), nil
}
func docxAddMarkers(p string, id int) string {
start := fmt.Sprintf(`<w:commentRangeStart w:id="%d"/>`, id)
end := fmt.Sprintf(`<w:commentRangeEnd w:id="%d"/><w:r><w:commentReference w:id="%d"/></w:r>`, id, id)
if loc := docxPPrRe.FindStringIndex(p); loc != nil {
p = p[:loc[1]] + start + p[loc[1]:]
} else if loc := docxOpenRe.FindStringIndex(p); loc != nil {
p = p[:loc[1]] + start + p[loc[1]:]
}
if i := strings.LastIndex(p, "</w:p>"); i >= 0 {
p = p[:i] + end + p[i:]
}
return p
}
func docxPlain(p string) string {
var b strings.Builder
for _, m := range docxTextRe.FindAllStringSubmatch(p, -1) {
b.WriteString(docxUnescape(m[1]))
}
return b.String()
}
func docxUnescape(s string) string {
var out strings.Builder
d := xml.NewDecoder(strings.NewReader("<x>" + s + "</x>"))
for {
tok, err := d.Token()
if err != nil {
break
}
if cd, ok := tok.(xml.CharData); ok {
out.Write(cd)
}
}
return out.String()
}
func docxBuildComments(author string, comments []DocxComment) []byte {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
b.WriteString(`<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
for i, c := range comments {
fmt.Fprintf(&b, `<w:comment w:id="%d" w:author="%s" w:initials="%s" w:date="%s"><w:p><w:r><w:t xml:space="preserve">%s</w:t></w:r></w:p></w:comment>`,
i, docxEscape(author), docxInitials(author), "2026-10-07T12:00:00Z", docxEscape(c.Text))
}
b.WriteString(`</w:comments>`)
return []byte(b.String())
}
func docxEscape(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
// docxInitials derives up to two initials from an author name.
func docxInitials(author string) string {
var out []rune
for _, f := range strings.Fields(author) {
if r := []rune(f); len(r) > 0 {
out = append(out, r[0])
}
if len(out) == 2 {
break
}
}
return strings.ToUpper(string(out))
}
func docxAddContentType(ct []byte) []byte {
if len(ct) == 0 || strings.Contains(string(ct), "/word/comments.xml") {
return ct
}
const override = `<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>`
return []byte(strings.Replace(string(ct), "</Types>", override+"</Types>", 1))
}
func docxAddRel(rels []byte) []byte {
if strings.Contains(string(rels), "relationships/comments") {
return rels
}
const rel = `<Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>`
return []byte(strings.Replace(string(rels), "</Relationships>", rel+"</Relationships>", 1))
}
func docxDefaultRels() []byte {
return []byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>`)
}
func readDocxZip(data []byte) (map[string][]byte, []string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, err
}
parts := make(map[string][]byte, len(zr.File))
order := make([]string, 0, len(zr.File))
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
return nil, nil, err
}
b, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, nil, err
}
parts[f.Name] = b
order = append(order, f.Name)
}
return parts, order, nil
}
func writeDocxZip(parts map[string][]byte, order []string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
written := map[string]bool{}
for _, name := range order {
data, ok := parts[name]
if !ok || written[name] {
continue
}
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
written[name] = true
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func containsStr(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
package onlyoffice
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
)
// buildTestDocx returns a minimal valid .docx with two paragraphs.
func buildTestDocx(t *testing.T) []byte {
t.Helper()
const (
contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:jc w:val="both"/></w:pPr><w:r><w:t>The term is two years, ending 8 October 2028.</w:t></w:r></w:p><w:p><w:r><w:t>Nothing relevant here.</w:t></w:r></w:p></w:body></w:document>`
rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range map[string]string{
"[Content_Types].xml": contentTypes,
"word/document.xml": document,
"word/_rels/document.xml.rels": rels,
} {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create: %v", err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func unzipPart(t *testing.T, data []byte, name string) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("zip reader: %v", err)
}
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open part: %v", err)
}
defer rc.Close()
b, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read part: %v", err)
}
return string(b)
}
t.Fatalf("part %s not found", name)
return ""
}
func TestInjectDocxComments(t *testing.T) {
src := buildTestDocx(t)
comments := []DocxComment{
{Anchor: "8 October 2028", Text: "Change to two years."},
{Anchor: "two years", Text: "second anchor, same paragraph"},
{Anchor: "no such text anywhere", Text: "skipped"},
}
// First anchor matches the first paragraph; the second also matches that
// paragraph but must be skipped (one comment per paragraph).
out, applied, err := InjectDocxComments(src, "Andriy Oblivantsev", comments)
if err != nil {
t.Fatalf("inject: %v", err)
}
if applied != 1 {
t.Fatalf("applied = %d, want 1", applied)
}
doc := unzipPart(t, out, "word/document.xml")
for _, want := range []string{"commentRangeStart", "commentRangeEnd", "commentReference"} {
if !strings.Contains(doc, want) {
t.Errorf("document.xml missing %s", want)
}
}
if strings.Contains(doc, `<w:pPr><w:commentRangeStart`) {
t.Error("commentRangeStart inserted inside pPr")
}
cmts := unzipPart(t, out, "word/comments.xml")
if strings.Count(cmts, "<w:comment ") != 1 {
t.Fatalf("comments count = %d, want 1", strings.Count(cmts, "<w:comment "))
}
if !strings.Contains(cmts, `w:author="Andriy Oblivantsev"`) {
t.Error("author missing in comments.xml")
}
if !strings.Contains(cmts, "Change to two years.") {
t.Error("comment text missing")
}
ct := unzipPart(t, out, "[Content_Types].xml")
if !strings.Contains(ct, "/word/comments.xml") {
t.Error("content types missing comments override")
}
rels := unzipPart(t, out, "word/_rels/document.xml.rels")
if !strings.Contains(rels, "relationships/comments") {
t.Error("document rels missing comments relationship")
}
}
func TestInjectDocxCommentsNoMatch(t *testing.T) {
src := buildTestDocx(t)
out, applied, err := InjectDocxComments(src, "X", []DocxComment{{Anchor: "absent", Text: "t"}})
if err != nil {
t.Fatalf("inject: %v", err)
}
if applied != 0 {
t.Fatalf("applied = %d, want 0", applied)
}
if out == nil {
t.Fatal("output nil")
}
}
func TestParseDocxComments(t *testing.T) {
got, err := ParseDocxComments([]byte(`[{"anchor":"a","text":"b"}]`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got) != 1 || got[0].Anchor != "a" || got[0].Text != "b" {
t.Fatalf("unexpected: %+v", got)
}
if _, err := ParseDocxComments([]byte(`{"anchor":"a"}`)); err == nil {
t.Fatal("expected error for object input")
}
}