1use serde::{Deserialize, Serialize};
7
8pub const THREADED_COMMENTS_NS: &str =
10 "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments";
11
12pub const THREADED_COMMENTS_CONTENT_TYPE: &str = "application/vnd.ms-excel.threadedcomments+xml";
14
15pub const PERSON_LIST_CONTENT_TYPE: &str = "application/vnd.ms-excel.person+xml";
17
18pub const REL_TYPE_THREADED_COMMENT: &str =
20 "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment";
21
22pub const REL_TYPE_PERSON: &str =
24 "http://schemas.microsoft.com/office/2017/10/relationships/person";
25
26#[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#[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#[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#[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}