Skip to main content

ebook_rs/
annotations.rs

1use ahash::AHashMap;
2use serde::{Deserialize, Serialize};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6static ATOMIC_ANN_ID: AtomicU64 = AtomicU64::new(1);
7
8/// Type of CFI-anchored user annotation.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum AnnotationType {
12    Highlight,
13    Underline,
14    Bookmark,
15    Note,
16}
17
18/// A user annotation entry.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Annotation {
21    pub id: String,
22    pub cfi_range: String,
23    pub type_: AnnotationType,
24    pub color: String,
25    pub note: Option<String>,
26    pub selected_text: Option<String>,
27    pub created_at: String,
28}
29
30/// Manager for annotations.
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct AnnotationManager {
33    annotations: AHashMap<String, Annotation>,
34}
35
36impl AnnotationManager {
37    pub fn new() -> Self {
38        Self {
39            annotations: AHashMap::new(),
40        }
41    }
42
43    /// Add an annotation entry.
44    pub fn add(&mut self, annotation: Annotation) {
45        self.annotations.insert(annotation.id.clone(), annotation);
46    }
47
48    /// Create a highlight annotation.
49    pub fn create_highlight(
50        &mut self,
51        cfi_range: &str,
52        color: &str,
53        selected_text: Option<&str>,
54        note: Option<&str>,
55    ) -> Annotation {
56        let ann = Annotation {
57            id: generate_unique_id("hl"),
58            cfi_range: cfi_range.to_string(),
59            type_: AnnotationType::Highlight,
60            color: color.to_string(),
61            note: note.map(|s| s.to_string()),
62            selected_text: selected_text.map(|s| s.to_string()),
63            created_at: current_timestamp_str(),
64        };
65        self.add(ann.clone());
66        ann
67    }
68
69    /// Create a bookmark annotation.
70    pub fn create_bookmark(&mut self, cfi: &str, note: Option<&str>) -> Annotation {
71        let ann = Annotation {
72            id: generate_unique_id("bm"),
73            cfi_range: cfi.to_string(),
74            type_: AnnotationType::Bookmark,
75            color: "#f59e0b".to_string(),
76            note: note.map(|s| s.to_string()),
77            selected_text: None,
78            created_at: current_timestamp_str(),
79        };
80        self.add(ann.clone());
81        ann
82    }
83
84    /// Get annotation by ID.
85    pub fn get(&self, id: &str) -> Option<&Annotation> {
86        self.annotations.get(id)
87    }
88
89    /// Remove an annotation by ID.
90    pub fn remove(&mut self, id: &str) -> bool {
91        self.annotations.remove(id).is_some()
92    }
93
94    /// List all annotations.
95    pub fn list(&self) -> Vec<Annotation> {
96        self.annotations.values().cloned().collect()
97    }
98
99    /// Export annotations as W3C Web Annotation Data Model (JSON-LD) format (F10 Fix).
100    pub fn to_w3c_json(&self) -> Result<String, String> {
101        let items: Vec<serde_json::Value> = self
102            .annotations
103            .values()
104            .map(|ann| {
105                serde_json::json!({
106                    "@context": "http://www.w3.org/ns/anno.jsonld",
107                    "id": format!("urn:annotation:{}", ann.id),
108                    "type": "Annotation",
109                    "motivation": match ann.type_ {
110                        AnnotationType::Highlight => "highlighting",
111                        AnnotationType::Bookmark => "bookmarking",
112                        AnnotationType::Underline => "underlining",
113                        AnnotationType::Note => "commenting",
114                    },
115                    "target": {
116                        "selector": {
117                            "type": "FragmentSelector",
118                            "conformsTo": "http://www.idpf.org/epub/linking/cfi/epub-cfi.html",
119                            "value": ann.cfi_range
120                        }
121                    },
122                    "body": {
123                        "type": "TextualBody",
124                        "value": ann.note.as_deref().unwrap_or(""),
125                        "format": "text/plain"
126                    },
127                    "created": ann.created_at
128                })
129            })
130            .collect();
131
132        serde_json::to_string(&items).map_err(|e| e.to_string())
133    }
134}
135
136/// B6 Fix: Generate 100% collision-free unique IDs using atomic sequence counters + timestamp.
137fn generate_unique_id(prefix: &str) -> String {
138    let seq = ATOMIC_ANN_ID.fetch_add(1, Ordering::Relaxed);
139    let nanos = SystemTime::now()
140        .duration_since(UNIX_EPOCH)
141        .unwrap_or_default()
142        .as_nanos();
143    format!("{}-{:x}-{:x}", prefix, nanos, seq)
144}
145
146fn current_timestamp_str() -> String {
147    let secs = SystemTime::now()
148        .duration_since(UNIX_EPOCH)
149        .unwrap_or_default()
150        .as_secs();
151    secs.to_string()
152}