Skip to main content

sheetkit_xml/
threaded_comment.rs

1//! Threaded comments XML schema structures.
2//!
3//! Represents `xl/threadedComments/threadedComment{N}.xml` and
4//! `xl/persons/person.xml` in the OOXML package (Excel 2019+).
5
6use serde::{Deserialize, Serialize};
7
8/// Namespace for threaded comments (Excel 2018+).
9pub const THREADED_COMMENTS_NS: &str =
10    "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments";
11
12/// Content type for threaded comments parts.
13pub const THREADED_COMMENTS_CONTENT_TYPE: &str = "application/vnd.ms-excel.threadedcomments+xml";
14
15/// Content type for the person list part.
16pub const PERSON_LIST_CONTENT_TYPE: &str = "application/vnd.ms-excel.person+xml";
17
18/// Relationship type for threaded comments (worksheet-level).
19pub const REL_TYPE_THREADED_COMMENT: &str =
20    "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment";
21
22/// Relationship type for the person list (workbook-level).
23pub const REL_TYPE_PERSON: &str =
24    "http://schemas.microsoft.com/office/2017/10/relationships/person";
25
26/// Root element for threaded comments XML.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(rename = "ThreadedComments")]
29pub struct ThreadedComments {
30    #[serde(rename = "@xmlns")]
31    pub xmlns: String,
32
33    #[serde(rename = "threadedComment", default)]
34    pub comments: Vec<ThreadedComment>,
35}
36
37/// Individual threaded comment entry.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(rename = "threadedComment")]
40pub struct ThreadedComment {
41    #[serde(rename = "@ref")]
42    pub cell_ref: String,
43
44    #[serde(rename = "@dT")]
45    pub date_time: String,
46
47    #[serde(rename = "@personId")]
48    pub person_id: String,
49
50    #[serde(rename = "@id")]
51    pub id: String,
52
53    #[serde(rename = "@parentId", skip_serializing_if = "Option::is_none")]
54    pub parent_id: Option<String>,
55
56    #[serde(rename = "@done", skip_serializing_if = "Option::is_none", default)]
57    pub done: Option<String>,
58
59    pub text: String,
60}
61
62/// Root element for the person list XML (`xl/persons/person.xml`).
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename = "personList")]
65pub struct PersonList {
66    #[serde(rename = "@xmlns")]
67    pub xmlns: String,
68
69    #[serde(rename = "person", default)]
70    pub persons: Vec<Person>,
71}
72
73/// Individual person entry in the person list.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct Person {
76    #[serde(rename = "@displayName")]
77    pub display_name: String,
78
79    #[serde(rename = "@id")]
80    pub id: String,
81
82    #[serde(rename = "@userId", skip_serializing_if = "Option::is_none")]
83    pub user_id: Option<String>,
84
85    #[serde(rename = "@providerId", skip_serializing_if = "Option::is_none")]
86    pub provider_id: Option<String>,
87}
88
89impl Default for ThreadedComments {
90    fn default() -> Self {
91        Self {
92            xmlns: THREADED_COMMENTS_NS.to_string(),
93            comments: Vec::new(),
94        }
95    }
96}
97
98impl Default for PersonList {
99    fn default() -> Self {
100        Self {
101            xmlns: THREADED_COMMENTS_NS.to_string(),
102            persons: Vec::new(),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_threaded_comments_default() {
113        let tc = ThreadedComments::default();
114        assert_eq!(tc.xmlns, THREADED_COMMENTS_NS);
115        assert!(tc.comments.is_empty());
116    }
117
118    #[test]
119    fn test_person_list_default() {
120        let pl = PersonList::default();
121        assert_eq!(pl.xmlns, THREADED_COMMENTS_NS);
122        assert!(pl.persons.is_empty());
123    }
124
125    #[test]
126    fn test_threaded_comment_roundtrip() {
127        let tc = ThreadedComments {
128            xmlns: THREADED_COMMENTS_NS.to_string(),
129            comments: vec![
130                ThreadedComment {
131                    cell_ref: "A1".to_string(),
132                    date_time: "2024-01-15T10:30:00.00".to_string(),
133                    person_id: "{PERSON-1}".to_string(),
134                    id: "{COMMENT-1}".to_string(),
135                    parent_id: None,
136                    done: None,
137                    text: "Initial comment".to_string(),
138                },
139                ThreadedComment {
140                    cell_ref: "A1".to_string(),
141                    date_time: "2024-01-15T11:00:00.00".to_string(),
142                    person_id: "{PERSON-2}".to_string(),
143                    id: "{REPLY-1}".to_string(),
144                    parent_id: Some("{COMMENT-1}".to_string()),
145                    done: Some("1".to_string()),
146                    text: "This is a reply".to_string(),
147                },
148            ],
149        };
150
151        let xml = quick_xml::se::to_string(&tc).unwrap();
152        assert!(xml.contains("A1"));
153        assert!(xml.contains("Initial comment"));
154        assert!(xml.contains("parentId"));
155        assert!(xml.contains("done=\"1\""));
156
157        let parsed: ThreadedComments = quick_xml::de::from_str(&xml).unwrap();
158        assert_eq!(parsed.comments.len(), 2);
159        assert_eq!(parsed.comments[0].cell_ref, "A1");
160        assert_eq!(parsed.comments[0].id, "{COMMENT-1}");
161        assert!(parsed.comments[0].parent_id.is_none());
162        assert_eq!(
163            parsed.comments[1].parent_id,
164            Some("{COMMENT-1}".to_string())
165        );
166        assert_eq!(parsed.comments[1].done, Some("1".to_string()));
167    }
168
169    #[test]
170    fn test_person_list_roundtrip() {
171        let pl = PersonList {
172            xmlns: THREADED_COMMENTS_NS.to_string(),
173            persons: vec![Person {
174                display_name: "John Doe".to_string(),
175                id: "{PERSON-GUID}".to_string(),
176                user_id: Some("user@example.com".to_string()),
177                provider_id: Some("ADAL".to_string()),
178            }],
179        };
180
181        let xml = quick_xml::se::to_string(&pl).unwrap();
182        assert!(xml.contains("John Doe"));
183        assert!(xml.contains("userId"));
184        assert!(xml.contains("providerId"));
185
186        let parsed: PersonList = quick_xml::de::from_str(&xml).unwrap();
187        assert_eq!(parsed.persons.len(), 1);
188        assert_eq!(parsed.persons[0].display_name, "John Doe");
189        assert_eq!(
190            parsed.persons[0].user_id,
191            Some("user@example.com".to_string())
192        );
193    }
194
195    #[test]
196    fn test_threaded_comment_without_optional_fields() {
197        let tc = ThreadedComment {
198            cell_ref: "B2".to_string(),
199            date_time: "2024-06-01T08:00:00.00".to_string(),
200            person_id: "{P1}".to_string(),
201            id: "{C1}".to_string(),
202            parent_id: None,
203            done: None,
204            text: "Simple comment".to_string(),
205        };
206
207        let xml = quick_xml::se::to_string(&tc).unwrap();
208        assert!(!xml.contains("parentId"));
209        assert!(!xml.contains("done"));
210
211        let parsed: ThreadedComment = quick_xml::de::from_str(&xml).unwrap();
212        assert!(parsed.parent_id.is_none());
213        assert!(parsed.done.is_none());
214    }
215
216    #[test]
217    fn test_person_without_optional_fields() {
218        let p = Person {
219            display_name: "Anonymous".to_string(),
220            id: "{P-ANON}".to_string(),
221            user_id: None,
222            provider_id: None,
223        };
224
225        let xml = quick_xml::se::to_string(&p).unwrap();
226        assert!(!xml.contains("userId"));
227        assert!(!xml.contains("providerId"));
228
229        let parsed: Person = quick_xml::de::from_str(&xml).unwrap();
230        assert!(parsed.user_id.is_none());
231        assert!(parsed.provider_id.is_none());
232    }
233
234    #[test]
235    fn test_parse_real_excel_threaded_comment_xml() {
236        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
237<ThreadedComments xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments">
238  <threadedComment ref="A1" dT="2024-01-15T10:30:00.00" personId="{GUID1}" id="{GUID2}">
239    <text>This is the initial comment</text>
240  </threadedComment>
241  <threadedComment ref="A1" dT="2024-01-15T11:00:00.00" personId="{GUID3}" id="{GUID4}" parentId="{GUID2}" done="1">
242    <text>This is a reply</text>
243  </threadedComment>
244</ThreadedComments>"#;
245
246        let parsed: ThreadedComments = quick_xml::de::from_str(xml).unwrap();
247        assert_eq!(parsed.comments.len(), 2);
248        assert_eq!(parsed.comments[0].text, "This is the initial comment");
249        assert_eq!(parsed.comments[1].text, "This is a reply");
250        assert_eq!(parsed.comments[1].parent_id, Some("{GUID2}".to_string()));
251        assert_eq!(parsed.comments[1].done, Some("1".to_string()));
252    }
253
254    #[test]
255    fn test_parse_real_excel_person_xml() {
256        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
257<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments">
258  <person displayName="John Doe" id="{GUID}" userId="user@example.com" providerId="ADAL"/>
259</personList>"#;
260
261        let parsed: PersonList = quick_xml::de::from_str(xml).unwrap();
262        assert_eq!(parsed.persons.len(), 1);
263        assert_eq!(parsed.persons[0].display_name, "John Doe");
264        assert_eq!(parsed.persons[0].id, "{GUID}");
265    }
266}