From 5414cc2d371d6e73a1a894e885a35240ddca34b6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 7 Oct 2026 18:31:51 +0100 Subject: [PATCH] =?UTF-8?q?feat(docs):=20oo=20docs=20comment=20=E2=80=94?= =?UTF-8?q?=20add=20Word=20comments=20to=20a=20.docx=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/oo/docs.go | 1 + cmd/oo/docs_comment.go | 156 +++++++++++++++++++++++++ docx_comment.go | 252 +++++++++++++++++++++++++++++++++++++++++ docx_comment_test.go | 138 ++++++++++++++++++++++ 4 files changed, 547 insertions(+) create mode 100644 cmd/oo/docs_comment.go create mode 100644 docx_comment.go create mode 100644 docx_comment_test.go diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 0fc85a4..42944bf 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -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 } diff --git a/cmd/oo/docs_comment.go b/cmd/oo/docs_comment.go new file mode 100644 index 0000000..ac00cd1 --- /dev/null +++ b/cmd/oo/docs_comment.go @@ -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":"","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 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: - 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" +} diff --git a/docx_comment.go b/docx_comment.go new file mode 100644 index 0000000..c50a8a8 --- /dev/null +++ b/docx_comment.go @@ -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 +} diff --git a/docx_comment_test.go b/docx_comment_test.go new file mode 100644 index 0000000..399f3b0 --- /dev/null +++ b/docx_comment_test.go @@ -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") + } +}