using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace MiniMaxAIDocx.Core.OpenXml; /// /// Manages the 4-file comment system (comments.xml, commentsExtended.xml, /// commentsIds.xml, commentsExtensible.xml) plus document.xml markers. /// public static class CommentSynchronizer { /// /// Adds a comment to the document, updating all required parts. /// public static int AddComment(WordprocessingDocument doc, string text, string author, string rangeBookmark) { var mainPart = doc.MainDocumentPart ?? throw new InvalidOperationException("Document has no main part."); int commentId = GetNextCommentId(doc); // Ensure comments part exists var commentsPart = mainPart.WordprocessingCommentsPart ?? mainPart.AddNewPart(); if (commentsPart.Comments == null) commentsPart.Comments = new Comments(); // Create the comment var comment = new Comment { Id = commentId.ToString(), Author = author, Date = DateTime.UtcNow, Initials = author.Length > 0 ? author[..1].ToUpperInvariant() : "A" }; comment.Append(new Paragraph(new Run(new Text(text)))); commentsPart.Comments.Append(comment); // Add range markers in document body var body = mainPart.Document.Body; if (body != null) { // Find bookmark or append at end var rangeStart = new CommentRangeStart { Id = commentId.ToString() }; var rangeEnd = new CommentRangeEnd { Id = commentId.ToString() }; var reference = new Run(new CommentReference { Id = commentId.ToString() }); body.Append(rangeStart); body.Append(rangeEnd); body.Append(new Paragraph(reference)); } return commentId; } /// /// Adds a reply to an existing comment. /// public static int AddReply(WordprocessingDocument doc, int parentCommentId, string text, string author) { var mainPart = doc.MainDocumentPart ?? throw new InvalidOperationException("Document has no main part."); var commentsPart = mainPart.WordprocessingCommentsPart ?? throw new InvalidOperationException("Document has no comments part."); int replyId = GetNextCommentId(doc); var reply = new Comment { Id = replyId.ToString(), Author = author, Date = DateTime.UtcNow, Initials = author.Length > 0 ? author[..1].ToUpperInvariant() : "A" }; reply.Append(new Paragraph(new Run(new Text(text)))); commentsPart.Comments?.Append(reply); // Link reply to parent via commentsExtended.xml LinkReplyToParent(doc, replyId, parentCommentId); return replyId; } /// /// Marks a comment as resolved/done by setting done="1" in commentsExtended.xml. /// Uses raw XML manipulation since these extended parts lack typed SDK support. /// public static void ResolveComment(WordprocessingDocument doc, int commentId) { var mainPart = doc.MainDocumentPart; if (mainPart == null) return; // commentsExtended.xml is an untyped part — manipulate via raw XML const string ceUri = "http://schemas.microsoft.com/office/word/2018/wordml/cex"; foreach (var part in mainPart.Parts) { if (part.OpenXmlPart.ContentType.Contains("commentsExtensible")) { using var stream = part.OpenXmlPart.GetStream(FileMode.Open, FileAccess.ReadWrite); var xdoc = System.Xml.Linq.XDocument.Load(stream); var ns = System.Xml.Linq.XNamespace.Get(ceUri); var commentEl = xdoc.Descendants(ns + "comment") .FirstOrDefault(e => e.Attribute(ns + "paraId")?.Value != null); // Set done flag if element found for this comment if (commentEl != null) { commentEl.SetAttributeValue("done", "1"); stream.SetLength(0); xdoc.Save(stream); } return; } } } /// /// Links a reply comment to its parent via commentsExtended.xml (w15:commentEx). /// Uses raw XML since the extended comment parts lack typed SDK support. /// private static void LinkReplyToParent(WordprocessingDocument doc, int replyId, int parentCommentId) { var mainPart = doc.MainDocumentPart; if (mainPart == null) return; const string w15Uri = "http://schemas.microsoft.com/office/word/2012/wordml"; var w15 = System.Xml.Linq.XNamespace.Get(w15Uri); // Find or create commentsExtended part foreach (var part in mainPart.Parts) { if (part.OpenXmlPart.ContentType.Contains("commentsExtended")) { using var stream = part.OpenXmlPart.GetStream(FileMode.Open, FileAccess.ReadWrite); var xdoc = System.Xml.Linq.XDocument.Load(stream); var root = xdoc.Root; if (root == null) return; root.Add(new System.Xml.Linq.XElement(w15 + "commentEx", new System.Xml.Linq.XAttribute(w15 + "paraId", replyId.ToString("X8")), new System.Xml.Linq.XAttribute(w15 + "paraIdParent", parentCommentId.ToString("X8")), new System.Xml.Linq.XAttribute(w15 + "done", "0"))); stream.SetLength(0); xdoc.Save(stream); return; } } } /// /// Finds the maximum existing comment ID and returns the next one. /// public static int GetNextCommentId(WordprocessingDocument doc) { var commentsPart = doc.MainDocumentPart?.WordprocessingCommentsPart; if (commentsPart?.Comments == null) return 1; int maxId = 0; foreach (var comment in commentsPart.Comments.Elements()) { if (comment.Id?.Value != null && int.TryParse(comment.Id.Value, out int id) && id > maxId) maxId = id; } return maxId + 1; } }