Skip to main content

cdx_core/extensions/collaboration/
change_tracking.rs

1//! Change tracking for Codex documents.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::content::Block;
7use crate::DocumentId;
8
9use super::{Collaborator, TextRange};
10
11/// Change tracking for a document.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ChangeTracking {
15    /// Base version this tracking is relative to.
16    pub base_version: DocumentId,
17
18    /// Tracked changes.
19    pub changes: Vec<TrackedChange>,
20
21    /// Whether change tracking is enabled.
22    #[serde(default = "default_true")]
23    pub enabled: bool,
24}
25
26fn default_true() -> bool {
27    true
28}
29
30impl ChangeTracking {
31    /// Create new change tracking.
32    #[must_use]
33    pub fn new(base_version: DocumentId) -> Self {
34        Self {
35            base_version,
36            changes: Vec::new(),
37            enabled: true,
38        }
39    }
40
41    /// Add a tracked change.
42    pub fn add_change(&mut self, change: TrackedChange) {
43        self.changes.push(change);
44    }
45
46    /// Get all pending changes.
47    #[must_use]
48    pub fn pending_changes(&self) -> Vec<&TrackedChange> {
49        self.changes
50            .iter()
51            .filter(|c| c.status == ChangeStatus::Pending)
52            .collect()
53    }
54
55    /// Get changes by author.
56    #[must_use]
57    pub fn changes_by_author(&self, author_name: &str) -> Vec<&TrackedChange> {
58        self.changes
59            .iter()
60            .filter(|c| c.author.name == author_name)
61            .collect()
62    }
63
64    /// Accept a change by ID.
65    ///
66    /// Returns `true` if the change was found and accepted.
67    pub fn accept_change(&mut self, change_id: &str) -> bool {
68        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
69            change.status = ChangeStatus::Accepted;
70            true
71        } else {
72            false
73        }
74    }
75
76    /// Reject a change by ID.
77    ///
78    /// Returns `true` if the change was found and rejected.
79    pub fn reject_change(&mut self, change_id: &str) -> bool {
80        if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
81            change.status = ChangeStatus::Rejected;
82            true
83        } else {
84            false
85        }
86    }
87
88    /// Accept all pending changes.
89    pub fn accept_all(&mut self) {
90        for change in &mut self.changes {
91            if change.status == ChangeStatus::Pending {
92                change.status = ChangeStatus::Accepted;
93            }
94        }
95    }
96
97    /// Reject all pending changes.
98    pub fn reject_all(&mut self) {
99        for change in &mut self.changes {
100            if change.status == ChangeStatus::Pending {
101                change.status = ChangeStatus::Rejected;
102            }
103        }
104    }
105
106    /// Get the number of changes.
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.changes.len()
110    }
111
112    /// Check if there are no changes.
113    #[must_use]
114    pub fn is_empty(&self) -> bool {
115        self.changes.is_empty()
116    }
117}
118
119/// A tracked change in the document.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct TrackedChange {
123    /// Unique identifier.
124    pub id: String,
125
126    /// Type of change.
127    pub change_type: ChangeType,
128
129    /// Reference to the affected block.
130    pub block_ref: String,
131
132    /// Content before the change (for modify/delete).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub before: Option<Box<Block>>,
135
136    /// Content after the change (for insert/modify).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub after: Option<Box<Block>>,
139
140    /// Text range affected (for inline changes).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub range: Option<TextRange>,
143
144    /// Original text (for inline changes).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub original_text: Option<String>,
147
148    /// New text (for inline changes).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub new_text: Option<String>,
151
152    /// Author of the change.
153    pub author: Collaborator,
154
155    /// When the change was made.
156    pub timestamp: DateTime<Utc>,
157
158    /// Current status of the change.
159    #[serde(default)]
160    pub status: ChangeStatus,
161
162    /// Optional note or reason for the change.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub note: Option<String>,
165}
166
167impl TrackedChange {
168    /// Create a new tracked change.
169    #[must_use]
170    pub fn new(
171        id: impl Into<String>,
172        change_type: ChangeType,
173        block_ref: impl Into<String>,
174        author: Collaborator,
175    ) -> Self {
176        Self {
177            id: id.into(),
178            change_type,
179            block_ref: block_ref.into(),
180            before: None,
181            after: None,
182            range: None,
183            original_text: None,
184            new_text: None,
185            author,
186            timestamp: Utc::now(),
187            status: ChangeStatus::Pending,
188            note: None,
189        }
190    }
191
192    /// Create an insertion change.
193    #[must_use]
194    pub fn insert(
195        id: impl Into<String>,
196        block_ref: impl Into<String>,
197        author: Collaborator,
198        content: Block,
199    ) -> Self {
200        Self::new(id, ChangeType::Insert, block_ref, author).with_after(content)
201    }
202
203    /// Create a deletion change.
204    #[must_use]
205    pub fn delete(
206        id: impl Into<String>,
207        block_ref: impl Into<String>,
208        author: Collaborator,
209        content: Block,
210    ) -> Self {
211        Self::new(id, ChangeType::Delete, block_ref, author).with_before(content)
212    }
213
214    /// Create a modification change.
215    #[must_use]
216    pub fn modify(
217        id: impl Into<String>,
218        block_ref: impl Into<String>,
219        author: Collaborator,
220        before: Block,
221        after: Block,
222    ) -> Self {
223        Self::new(id, ChangeType::Modify, block_ref, author)
224            .with_before(before)
225            .with_after(after)
226    }
227
228    /// Create an inline text change.
229    #[must_use]
230    pub fn inline_text(
231        id: impl Into<String>,
232        block_ref: impl Into<String>,
233        author: Collaborator,
234        range: TextRange,
235        original: impl Into<String>,
236        replacement: impl Into<String>,
237    ) -> Self {
238        Self::new(id, ChangeType::Modify, block_ref, author)
239            .with_range(range)
240            .with_text_change(original, replacement)
241    }
242
243    /// Set the before state.
244    #[must_use]
245    pub fn with_before(mut self, block: Block) -> Self {
246        self.before = Some(Box::new(block));
247        self
248    }
249
250    /// Set the after state.
251    #[must_use]
252    pub fn with_after(mut self, block: Block) -> Self {
253        self.after = Some(Box::new(block));
254        self
255    }
256
257    /// Set the text range.
258    #[must_use]
259    pub fn with_range(mut self, range: TextRange) -> Self {
260        self.range = Some(range);
261        self
262    }
263
264    /// Set inline text change.
265    #[must_use]
266    pub fn with_text_change(mut self, original: impl Into<String>, new: impl Into<String>) -> Self {
267        self.original_text = Some(original.into());
268        self.new_text = Some(new.into());
269        self
270    }
271
272    /// Set a note.
273    #[must_use]
274    pub fn with_note(mut self, note: impl Into<String>) -> Self {
275        self.note = Some(note.into());
276        self
277    }
278
279    /// Accept this change.
280    pub fn accept(&mut self) {
281        self.status = ChangeStatus::Accepted;
282    }
283
284    /// Reject this change.
285    pub fn reject(&mut self) {
286        self.status = ChangeStatus::Rejected;
287    }
288
289    /// Check if this change is pending.
290    #[must_use]
291    pub fn is_pending(&self) -> bool {
292        self.status == ChangeStatus::Pending
293    }
294}
295
296/// Type of tracked change.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
298#[serde(rename_all = "lowercase")]
299#[strum(serialize_all = "lowercase")]
300pub enum ChangeType {
301    /// Content was inserted.
302    Insert,
303    /// Content was deleted.
304    Delete,
305    /// Content was modified.
306    Modify,
307    /// Content was moved.
308    Move,
309    /// Formatting was changed.
310    Format,
311}
312
313/// Status of a tracked change.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
315#[serde(rename_all = "lowercase")]
316#[strum(serialize_all = "lowercase")]
317pub enum ChangeStatus {
318    /// Change is pending review.
319    #[default]
320    Pending,
321    /// Change has been accepted.
322    Accepted,
323    /// Change has been rejected.
324    Rejected,
325}