use crate::clip::ClipId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
pub struct UserId(pub String);
impl UserId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OpId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnnotationField {
Note,
Tags,
SceneDescription,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TextOp {
Insert {
pos: usize,
text: String,
},
Delete {
pos: usize,
len: usize,
},
Replace {
text: String,
},
}
impl TextOp {
#[must_use]
pub fn apply(&self, current: &str) -> String {
match self {
Self::Insert { pos, text } => {
let pos = (*pos).min(current.len());
let mut s = current.to_string();
s.insert_str(pos, text);
s
}
Self::Delete { pos, len } => {
let pos = (*pos).min(current.len());
let end = (pos + len).min(current.len());
let mut s = current.to_string();
s.replace_range(pos..end, "");
s
}
Self::Replace { text } => text.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotationOp {
pub id: OpId,
pub user_id: UserId,
pub clip_id: ClipId,
pub field: AnnotationField,
pub op: TextOp,
pub base_revision: u64,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FieldState {
pub text: String,
pub revision: u64,
pub last_editor: Option<UserId>,
pub history: Vec<AnnotationOp>,
}
impl FieldState {
pub fn apply(&mut self, op: AnnotationOp) {
self.text = op.op.apply(&self.text);
self.revision += 1;
self.last_editor = Some(op.user_id.clone());
self.history.push(op);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conflict {
pub winner: AnnotationOp,
pub loser_original: AnnotationOp,
pub loser_rebased: AnnotationOp,
pub detected_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
pub struct SharedAnnotationBoard {
fields: HashMap<(ClipId, String), FieldState>,
next_op_id: u64,
pub conflicts: Vec<Conflict>,
}
impl SharedAnnotationBoard {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, clip_id: &ClipId, field: &AnnotationField) -> Option<&str> {
self.fields
.get(&(*clip_id, Self::field_key(field)))
.map(|fs| fs.text.as_str())
}
#[must_use]
pub fn revision(&self, clip_id: &ClipId, field: &AnnotationField) -> u64 {
self.fields
.get(&(*clip_id, Self::field_key(field)))
.map_or(0, |fs| fs.revision)
}
pub fn submit(&mut self, mut op: AnnotationOp) -> AnnotationOp {
let key = (op.clip_id, Self::field_key(&op.field));
let field_state = self.fields.entry(key.clone()).or_default();
op.id = OpId(self.next_op_id);
self.next_op_id += 1;
let current_rev = field_state.revision;
if op.base_revision == current_rev {
field_state.apply(op.clone());
return op;
}
let winner = field_state
.history
.iter()
.rev()
.find(|h| h.base_revision >= op.base_revision)
.cloned();
if let Some(winner_op) = winner {
let (winner_final, mut loser_final) = if winner_op.user_id >= op.user_id {
(winner_op.clone(), op.clone())
} else {
(op.clone(), winner_op.clone())
};
let rebased_text_op = rebase_op(&loser_final.op, &winner_final.op);
loser_final.op = rebased_text_op;
loser_final.base_revision = current_rev;
self.conflicts.push(Conflict {
winner: winner_final,
loser_original: op.clone(),
loser_rebased: loser_final.clone(),
detected_at: Utc::now(),
});
field_state.apply(loser_final.clone());
return loser_final;
}
field_state.apply(op.clone());
op
}
#[must_use]
pub fn annotations_for_clip(&self, clip_id: &ClipId) -> HashMap<String, String> {
self.fields
.iter()
.filter(|((cid, _), _)| cid == clip_id)
.map(|((_, field), state)| (field.clone(), state.text.clone()))
.collect()
}
#[must_use]
pub fn history(&self, clip_id: &ClipId, field: &AnnotationField) -> Vec<&AnnotationOp> {
self.fields
.get(&(*clip_id, Self::field_key(field)))
.map_or(Vec::new(), |fs| fs.history.iter().collect())
}
fn field_key(field: &AnnotationField) -> String {
match field {
AnnotationField::Note => "note".to_string(),
AnnotationField::Tags => "tags".to_string(),
AnnotationField::SceneDescription => "scene_description".to_string(),
AnnotationField::Custom(name) => format!("custom:{name}"),
}
}
}
fn rebase_op(loser: &TextOp, winner: &TextOp) -> TextOp {
match (loser, winner) {
(TextOp::Insert { pos: lp, text: lt }, TextOp::Insert { pos: wp, text: wt }) => {
let new_pos = if *wp <= *lp { lp + wt.len() } else { *lp };
TextOp::Insert {
pos: new_pos,
text: lt.clone(),
}
}
(TextOp::Insert { pos: lp, text: lt }, TextOp::Delete { pos: wp, len: wl }) => {
let new_pos = if *wp < *lp {
lp.saturating_sub(*wl)
} else {
*lp
};
TextOp::Insert {
pos: new_pos,
text: lt.clone(),
}
}
(TextOp::Delete { pos: lp, len: ll }, TextOp::Insert { pos: wp, text: wt }) => {
let new_pos = if *wp <= *lp { lp + wt.len() } else { *lp };
TextOp::Delete {
pos: new_pos,
len: *ll,
}
}
(TextOp::Delete { pos: lp, len: ll }, TextOp::Delete { pos: wp, len: wl }) => {
let new_pos = if *wp < *lp {
lp.saturating_sub(*wl)
} else {
*lp
};
TextOp::Delete {
pos: new_pos,
len: *ll,
}
}
_ => loser.clone(),
}
}
pub struct CollaborationSession {
pub user_id: UserId,
}
impl CollaborationSession {
#[must_use]
pub fn new(user_id: UserId) -> Self {
Self { user_id }
}
#[must_use]
pub fn insert_op(
&self,
clip_id: ClipId,
field: AnnotationField,
pos: usize,
text: impl Into<String>,
base_revision: u64,
) -> AnnotationOp {
AnnotationOp {
id: OpId(0), user_id: self.user_id.clone(),
clip_id,
field,
op: TextOp::Insert {
pos,
text: text.into(),
},
base_revision,
timestamp: Utc::now(),
}
}
#[must_use]
pub fn replace_op(
&self,
clip_id: ClipId,
field: AnnotationField,
text: impl Into<String>,
base_revision: u64,
) -> AnnotationOp {
AnnotationOp {
id: OpId(0),
user_id: self.user_id.clone(),
clip_id,
field,
op: TextOp::Replace { text: text.into() },
base_revision,
timestamp: Utc::now(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn new_clip() -> ClipId {
crate::clip::Clip::new(PathBuf::from("/test.mov")).id
}
#[test]
fn test_text_op_insert() {
let op = TextOp::Insert {
pos: 5,
text: "XYZ".to_string(),
};
let result = op.apply("hello world");
assert_eq!(result, "helloXYZ world");
}
#[test]
fn test_text_op_delete() {
let op = TextOp::Delete { pos: 5, len: 6 };
let result = op.apply("hello world");
assert_eq!(result, "hello");
}
#[test]
fn test_text_op_replace() {
let op = TextOp::Replace {
text: "brand new".to_string(),
};
let result = op.apply("old text");
assert_eq!(result, "brand new");
}
#[test]
fn test_board_clean_apply() {
let mut board = SharedAnnotationBoard::new();
let clip_id = new_clip();
let session = CollaborationSession::new(UserId::new("alice"));
let op = session.replace_op(clip_id, AnnotationField::Note, "Hello", 0);
board.submit(op);
assert_eq!(board.get(&clip_id, &AnnotationField::Note), Some("Hello"));
assert_eq!(board.revision(&clip_id, &AnnotationField::Note), 1);
}
#[test]
fn test_board_two_sequential_inserts() {
let mut board = SharedAnnotationBoard::new();
let clip_id = new_clip();
let alice = CollaborationSession::new(UserId::new("alice"));
let bob = CollaborationSession::new(UserId::new("bob"));
let op1 = alice.replace_op(clip_id, AnnotationField::Note, "Hello", 0);
board.submit(op1);
let rev = board.revision(&clip_id, &AnnotationField::Note);
let op2 = bob.insert_op(clip_id, AnnotationField::Note, 5, " World", rev);
board.submit(op2);
assert_eq!(
board.get(&clip_id, &AnnotationField::Note),
Some("Hello World")
);
}
#[test]
fn test_board_conflict_detection() {
let mut board = SharedAnnotationBoard::new();
let clip_id = new_clip();
let alice = CollaborationSession::new(UserId::new("alice"));
let bob = CollaborationSession::new(UserId::new("bob"));
let op0 = alice.replace_op(clip_id, AnnotationField::Note, "Base text", 0);
board.submit(op0);
let op_alice = alice.replace_op(clip_id, AnnotationField::Note, "Alice edit", 1);
let op_bob = bob.replace_op(clip_id, AnnotationField::Note, "Bob edit", 1);
board.submit(op_alice);
board.submit(op_bob);
assert!(!board.conflicts.is_empty());
}
#[test]
fn test_board_history_recorded() {
let mut board = SharedAnnotationBoard::new();
let clip_id = new_clip();
let alice = CollaborationSession::new(UserId::new("alice"));
let op1 = alice.replace_op(clip_id, AnnotationField::Note, "v1", 0);
let op2 = alice.replace_op(clip_id, AnnotationField::Note, "v2", 1);
board.submit(op1);
let rev = board.revision(&clip_id, &AnnotationField::Note);
let mut op2 = op2;
op2.base_revision = rev;
board.submit(op2);
let hist = board.history(&clip_id, &AnnotationField::Note);
assert_eq!(hist.len(), 2);
}
#[test]
fn test_annotations_for_clip() {
let mut board = SharedAnnotationBoard::new();
let clip_id = new_clip();
let alice = CollaborationSession::new(UserId::new("alice"));
let op_note = alice.replace_op(clip_id, AnnotationField::Note, "Great take", 0);
let op_tags = alice.replace_op(clip_id, AnnotationField::Tags, "interview,day", 0);
board.submit(op_note);
board.submit(op_tags);
let annots = board.annotations_for_clip(&clip_id);
assert_eq!(annots.get("note").map(|s| s.as_str()), Some("Great take"));
assert_eq!(
annots.get("tags").map(|s| s.as_str()),
Some("interview,day")
);
}
#[test]
fn test_rebase_insert_after_prior_insert() {
let winner = TextOp::Insert {
pos: 0,
text: "AAA".to_string(),
};
let loser = TextOp::Insert {
pos: 3,
text: "BBB".to_string(),
};
let rebased = rebase_op(&loser, &winner);
match rebased {
TextOp::Insert { pos, .. } => assert_eq!(pos, 6),
_ => panic!("Expected Insert"),
}
}
}