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 chrono::Utc::now().to_rfc3339()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_annotations_file_new() {
242 let file = AnnotationsFile::new(vec![]);
243 assert!(file.is_empty());
244 assert_eq!(file.len(), 0);
245 assert_eq!(file.version, Some("0.1".to_string()));
246 }
247
248 #[test]
249 fn test_annotations_file_add() {
250 let mut file = AnnotationsFile::empty();
251 let anno = Annotation::comment("a1", ContentAnchor::block("block-1"), "Author", "Comment");
252 file.add(anno);
253
254 assert_eq!(file.len(), 1);
255 assert!(!file.is_empty());
256 }
257
258 #[test]
259 fn test_annotations_file_get() {
260 let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
261 let anno2 = Annotation::note("a2", ContentAnchor::block("b2"), "Auth", "Text 2");
262 let file = AnnotationsFile::new(vec![anno1, anno2]);
263
264 assert!(file.get("a1").is_some());
265 assert!(file.get("a2").is_some());
266 assert!(file.get("nonexistent").is_none());
267 }
268
269 #[test]
270 fn test_annotations_file_for_block() {
271 let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
272 let anno2 = Annotation::note("a2", ContentAnchor::block("b1"), "Auth", "Text 2");
273 let anno3 = Annotation::highlight("a3", ContentAnchor::block("b2"), "Auth", "Text 3");
274 let file = AnnotationsFile::new(vec![anno1, anno2, anno3]);
275
276 let b1_annos = file.for_block("b1");
277 assert_eq!(b1_annos.len(), 2);
278
279 let b2_annos = file.for_block("b2");
280 assert_eq!(b2_annos.len(), 1);
281 }
282
283 #[test]
284 fn test_annotation_new() {
285 let anchor = ContentAnchor::range("block-1", 10, 25);
286 let anno = Annotation::new(
287 "anno-1",
288 AnnotationType::Comment,
289 anchor,
290 "Alice",
291 "A comment",
292 );
293
294 assert_eq!(anno.id, "anno-1");
295 assert_eq!(anno.annotation_type, AnnotationType::Comment);
296 assert_eq!(anno.author, "Alice");
297 assert_eq!(anno.content, "A comment");
298 }
299
300 #[test]
301 fn test_annotation_convenience_constructors() {
302 let anchor = ContentAnchor::block("b1");
303
304 let comment = Annotation::comment("c1", anchor.clone(), "Auth", "Comment text");
305 assert_eq!(comment.annotation_type, AnnotationType::Comment);
306
307 let highlight = Annotation::highlight("h1", anchor.clone(), "Auth", "Highlight note");
308 assert_eq!(highlight.annotation_type, AnnotationType::Highlight);
309
310 let note = Annotation::note("n1", anchor.clone(), "Auth", "Note text");
311 assert_eq!(note.annotation_type, AnnotationType::Note);
312
313 let reaction = Annotation::reaction("r1", anchor, "Auth", "thumbsup");
314 assert_eq!(reaction.annotation_type, AnnotationType::Reaction);
315 assert_eq!(reaction.content, "thumbsup");
316 }
317
318 #[test]
319 fn test_annotation_serialization() {
320 let anchor = ContentAnchor::range("para-1", 0, 10);
321 let anno = Annotation::comment("a1", anchor, "Reviewer", "Needs work")
322 .with_created("2025-01-15T10:00:00Z");
323
324 let json = serde_json::to_string(&anno).unwrap();
325 assert!(json.contains("\"id\":\"a1\""));
326 assert!(json.contains("\"type\":\"comment\""));
327 assert!(json.contains("\"author\":\"Reviewer\""));
328 assert!(json.contains("\"content\":\"Needs work\""));
329 assert!(json.contains("\"blockId\":\"para-1\""));
330 }
331
332 #[test]
333 fn test_annotation_deserialization() {
334 let json = r#"{
335 "id": "anno-1",
336 "type": "highlight",
337 "anchor": {"blockId": "block-1", "start": 5, "end": 15},
338 "author": "Bob",
339 "created": "2025-01-15T12:00:00Z",
340 "content": "Important section"
341 }"#;
342
343 let anno: Annotation = serde_json::from_str(json).unwrap();
344 assert_eq!(anno.id, "anno-1");
345 assert_eq!(anno.annotation_type, AnnotationType::Highlight);
346 assert_eq!(anno.anchor.block_id, "block-1");
347 assert_eq!(anno.anchor.start, Some(5));
348 assert_eq!(anno.anchor.end, Some(15));
349 assert_eq!(anno.author, "Bob");
350 assert_eq!(anno.content, "Important section");
351 }
352
353 #[test]
354 fn test_annotations_file_serialization() {
355 let anno = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Comment")
356 .with_created("2025-01-15T10:00:00Z");
357 let file = AnnotationsFile::new(vec![anno]);
358
359 let json = serde_json::to_string_pretty(&file).unwrap();
360 assert!(json.contains("\"version\": \"0.1\""));
361 assert!(json.contains("\"annotations\""));
362 assert!(json.contains("\"type\": \"comment\""));
363 }
364
365 #[test]
366 fn test_annotations_file_deserialization() {
367 let json = r#"{
368 "version": "0.1",
369 "annotations": [
370 {
371 "id": "a1",
372 "type": "note",
373 "anchor": {"blockId": "intro"},
374 "author": "Editor",
375 "created": "2025-01-15T10:00:00Z",
376 "content": "Consider rephrasing."
377 }
378 ]
379 }"#;
380
381 let file: AnnotationsFile = serde_json::from_str(json).unwrap();
382 assert_eq!(file.version, Some("0.1".to_string()));
383 assert_eq!(file.len(), 1);
384
385 let anno = &file.annotations[0];
386 assert_eq!(anno.id, "a1");
387 assert_eq!(anno.annotation_type, AnnotationType::Note);
388 }
389
390 #[test]
391 fn test_annotation_type_display() {
392 assert_eq!(AnnotationType::Comment.to_string(), "comment");
393 assert_eq!(AnnotationType::Highlight.to_string(), "highlight");
394 assert_eq!(AnnotationType::Note.to_string(), "note");
395 assert_eq!(AnnotationType::Reaction.to_string(), "reaction");
396 }
397
398 #[test]
399 fn test_annotation_type_as_str() {
400 assert_eq!(AnnotationType::Comment.as_str(), "comment");
401 assert_eq!(AnnotationType::Highlight.as_str(), "highlight");
402 assert_eq!(AnnotationType::Note.as_str(), "note");
403 assert_eq!(AnnotationType::Reaction.as_str(), "reaction");
404 }
405}