1use serde::{Deserialize, Serialize};
32
33use crate::anchor::ContentAnchor;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AnnotationsFile {
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub version: Option<String>,
44
45 pub annotations: Vec<Annotation>,
47}
48
49impl AnnotationsFile {
50 #[must_use]
52 pub fn new(annotations: Vec<Annotation>) -> Self {
53 Self {
54 version: Some(crate::SPEC_VERSION.to_string()),
55 annotations,
56 }
57 }
58
59 #[must_use]
61 pub fn empty() -> Self {
62 Self::new(Vec::new())
63 }
64
65 pub fn add(&mut self, annotation: Annotation) {
67 self.annotations.push(annotation);
68 }
69
70 #[must_use]
72 pub fn get(&self, id: &str) -> Option<&Annotation> {
73 self.annotations.iter().find(|a| a.id == id)
74 }
75
76 #[must_use]
78 pub fn for_block(&self, block_id: &str) -> Vec<&Annotation> {
79 self.annotations
80 .iter()
81 .filter(|a| a.anchor.block_id == block_id)
82 .collect()
83 }
84
85 #[must_use]
87 pub fn is_empty(&self) -> bool {
88 self.annotations.is_empty()
89 }
90
91 #[must_use]
93 pub fn len(&self) -> usize {
94 self.annotations.len()
95 }
96}
97
98impl Default for AnnotationsFile {
99 fn default() -> Self {
100 Self::empty()
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Annotation {
111 pub id: String,
113
114 #[serde(rename = "type")]
116 pub annotation_type: AnnotationType,
117
118 pub anchor: ContentAnchor,
120
121 pub author: String,
123
124 pub created: String,
126
127 pub content: String,
129}
130
131impl Annotation {
132 #[must_use]
134 pub fn new(
135 id: impl Into<String>,
136 annotation_type: AnnotationType,
137 anchor: ContentAnchor,
138 author: impl Into<String>,
139 content: impl Into<String>,
140 ) -> Self {
141 Self {
142 id: id.into(),
143 annotation_type,
144 anchor,
145 author: author.into(),
146 created: chrono_now(),
147 content: content.into(),
148 }
149 }
150
151 #[must_use]
153 pub fn comment(
154 id: impl Into<String>,
155 anchor: ContentAnchor,
156 author: impl Into<String>,
157 content: impl Into<String>,
158 ) -> Self {
159 Self::new(id, AnnotationType::Comment, anchor, author, content)
160 }
161
162 #[must_use]
164 pub fn highlight(
165 id: impl Into<String>,
166 anchor: ContentAnchor,
167 author: impl Into<String>,
168 content: impl Into<String>,
169 ) -> Self {
170 Self::new(id, AnnotationType::Highlight, anchor, author, content)
171 }
172
173 #[must_use]
175 pub fn note(
176 id: impl Into<String>,
177 anchor: ContentAnchor,
178 author: impl Into<String>,
179 content: impl Into<String>,
180 ) -> Self {
181 Self::new(id, AnnotationType::Note, anchor, author, content)
182 }
183
184 #[must_use]
186 pub fn reaction(
187 id: impl Into<String>,
188 anchor: ContentAnchor,
189 author: impl Into<String>,
190 emoji: impl Into<String>,
191 ) -> Self {
192 Self::new(id, AnnotationType::Reaction, anchor, author, emoji)
193 }
194
195 #[must_use]
197 pub fn with_created(mut self, created: impl Into<String>) -> Self {
198 self.created = created.into();
199 self
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
205#[serde(rename_all = "lowercase")]
206#[strum(serialize_all = "lowercase")]
207pub enum AnnotationType {
208 Comment,
210 Highlight,
212 Note,
214 Reaction,
216}
217
218impl AnnotationType {
219 #[must_use]
221 pub const fn as_str(&self) -> &'static str {
222 match self {
223 Self::Comment => "comment",
224 Self::Highlight => "highlight",
225 Self::Note => "note",
226 Self::Reaction => "reaction",
227 }
228 }
229}
230
231fn chrono_now() -> String {
233 "2025-01-01T00:00:00Z".to_string()
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_annotations_file_new() {
245 let file = AnnotationsFile::new(vec![]);
246 assert!(file.is_empty());
247 assert_eq!(file.len(), 0);
248 assert_eq!(file.version, Some("0.1".to_string()));
249 }
250
251 #[test]
252 fn test_annotations_file_add() {
253 let mut file = AnnotationsFile::empty();
254 let anno = Annotation::comment("a1", ContentAnchor::block("block-1"), "Author", "Comment");
255 file.add(anno);
256
257 assert_eq!(file.len(), 1);
258 assert!(!file.is_empty());
259 }
260
261 #[test]
262 fn test_annotations_file_get() {
263 let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
264 let anno2 = Annotation::note("a2", ContentAnchor::block("b2"), "Auth", "Text 2");
265 let file = AnnotationsFile::new(vec![anno1, anno2]);
266
267 assert!(file.get("a1").is_some());
268 assert!(file.get("a2").is_some());
269 assert!(file.get("nonexistent").is_none());
270 }
271
272 #[test]
273 fn test_annotations_file_for_block() {
274 let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
275 let anno2 = Annotation::note("a2", ContentAnchor::block("b1"), "Auth", "Text 2");
276 let anno3 = Annotation::highlight("a3", ContentAnchor::block("b2"), "Auth", "Text 3");
277 let file = AnnotationsFile::new(vec![anno1, anno2, anno3]);
278
279 let b1_annos = file.for_block("b1");
280 assert_eq!(b1_annos.len(), 2);
281
282 let b2_annos = file.for_block("b2");
283 assert_eq!(b2_annos.len(), 1);
284 }
285
286 #[test]
287 fn test_annotation_new() {
288 let anchor = ContentAnchor::range("block-1", 10, 25);
289 let anno = Annotation::new(
290 "anno-1",
291 AnnotationType::Comment,
292 anchor,
293 "Alice",
294 "A comment",
295 );
296
297 assert_eq!(anno.id, "anno-1");
298 assert_eq!(anno.annotation_type, AnnotationType::Comment);
299 assert_eq!(anno.author, "Alice");
300 assert_eq!(anno.content, "A comment");
301 }
302
303 #[test]
304 fn test_annotation_convenience_constructors() {
305 let anchor = ContentAnchor::block("b1");
306
307 let comment = Annotation::comment("c1", anchor.clone(), "Auth", "Comment text");
308 assert_eq!(comment.annotation_type, AnnotationType::Comment);
309
310 let highlight = Annotation::highlight("h1", anchor.clone(), "Auth", "Highlight note");
311 assert_eq!(highlight.annotation_type, AnnotationType::Highlight);
312
313 let note = Annotation::note("n1", anchor.clone(), "Auth", "Note text");
314 assert_eq!(note.annotation_type, AnnotationType::Note);
315
316 let reaction = Annotation::reaction("r1", anchor, "Auth", "thumbsup");
317 assert_eq!(reaction.annotation_type, AnnotationType::Reaction);
318 assert_eq!(reaction.content, "thumbsup");
319 }
320
321 #[test]
322 fn test_annotation_serialization() {
323 let anchor = ContentAnchor::range("para-1", 0, 10);
324 let anno = Annotation::comment("a1", anchor, "Reviewer", "Needs work")
325 .with_created("2025-01-15T10:00:00Z");
326
327 let json = serde_json::to_string(&anno).unwrap();
328 assert!(json.contains("\"id\":\"a1\""));
329 assert!(json.contains("\"type\":\"comment\""));
330 assert!(json.contains("\"author\":\"Reviewer\""));
331 assert!(json.contains("\"content\":\"Needs work\""));
332 assert!(json.contains("\"blockId\":\"para-1\""));
333 }
334
335 #[test]
336 fn test_annotation_deserialization() {
337 let json = r#"{
338 "id": "anno-1",
339 "type": "highlight",
340 "anchor": {"blockId": "block-1", "start": 5, "end": 15},
341 "author": "Bob",
342 "created": "2025-01-15T12:00:00Z",
343 "content": "Important section"
344 }"#;
345
346 let anno: Annotation = serde_json::from_str(json).unwrap();
347 assert_eq!(anno.id, "anno-1");
348 assert_eq!(anno.annotation_type, AnnotationType::Highlight);
349 assert_eq!(anno.anchor.block_id, "block-1");
350 assert_eq!(anno.anchor.start, Some(5));
351 assert_eq!(anno.anchor.end, Some(15));
352 assert_eq!(anno.author, "Bob");
353 assert_eq!(anno.content, "Important section");
354 }
355
356 #[test]
357 fn test_annotations_file_serialization() {
358 let anno = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Comment")
359 .with_created("2025-01-15T10:00:00Z");
360 let file = AnnotationsFile::new(vec![anno]);
361
362 let json = serde_json::to_string_pretty(&file).unwrap();
363 assert!(json.contains("\"version\": \"0.1\""));
364 assert!(json.contains("\"annotations\""));
365 assert!(json.contains("\"type\": \"comment\""));
366 }
367
368 #[test]
369 fn test_annotations_file_deserialization() {
370 let json = r#"{
371 "version": "0.1",
372 "annotations": [
373 {
374 "id": "a1",
375 "type": "note",
376 "anchor": {"blockId": "intro"},
377 "author": "Editor",
378 "created": "2025-01-15T10:00:00Z",
379 "content": "Consider rephrasing."
380 }
381 ]
382 }"#;
383
384 let file: AnnotationsFile = serde_json::from_str(json).unwrap();
385 assert_eq!(file.version, Some("0.1".to_string()));
386 assert_eq!(file.len(), 1);
387
388 let anno = &file.annotations[0];
389 assert_eq!(anno.id, "a1");
390 assert_eq!(anno.annotation_type, AnnotationType::Note);
391 }
392
393 #[test]
394 fn test_annotation_type_display() {
395 assert_eq!(AnnotationType::Comment.to_string(), "comment");
396 assert_eq!(AnnotationType::Highlight.to_string(), "highlight");
397 assert_eq!(AnnotationType::Note.to_string(), "note");
398 assert_eq!(AnnotationType::Reaction.to_string(), "reaction");
399 }
400
401 #[test]
402 fn test_annotation_type_as_str() {
403 assert_eq!(AnnotationType::Comment.as_str(), "comment");
404 assert_eq!(AnnotationType::Highlight.as_str(), "highlight");
405 assert_eq!(AnnotationType::Note.as_str(), "note");
406 assert_eq!(AnnotationType::Reaction.as_str(), "reaction");
407 }
408}