use super::{StoreError, Undo}; use crate::State; use chrono::{DateTime, Utc}; use hyaenidae_content::{bbcode, html}; use sled::{Db, Transactional, Tree}; use std::io::Cursor; use uuid::Uuid; #[derive(Clone, Debug)] pub struct Comment { id: Uuid, submission_id: Uuid, profile_id: Uuid, comment_id: Option, body: String, body_source: Option, published: Option>, updated: Option>, deleted: bool, } #[derive(Debug)] pub enum CommentChangesKind { Create { profile_id: Uuid, submission_id: Uuid, comment_id: Option, }, Update { id: Uuid, }, } #[derive(Debug)] pub struct CommentChanges<'a> { state: &'a State, kind: CommentChangesKind, body: Option, body_source: Option, published: Option>, updated: Option>, } #[derive(Clone, Debug)] pub struct Store { comment_tree: Tree, submission_tree: Tree, submission_created_tree: Tree, reply_created_tree: Tree, created_tree: Tree, count_tree: Tree, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] struct StoredComment { id: Uuid, submission_id: Uuid, profile_id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] comment_id: Option, body: String, #[serde(skip_serializing_if = "Option::is_none")] body_source: Option, #[serde(skip_serializing_if = "Option::is_none")] published: Option>, #[serde(skip_serializing_if = "Option::is_none")] updated: Option>, created_at: DateTime, updated_at: DateTime, #[serde(skip_serializing_if = "Option::is_none")] deleted_at: Option>, } impl Comment { pub fn id(&self) -> Uuid { self.id } pub fn submission_id(&self) -> Uuid { self.submission_id } pub fn profile_id(&self) -> Uuid { self.profile_id } pub fn comment_id(&self) -> Option { self.comment_id } pub fn body(&self) -> &str { if !self.deleted() { self.body.as_str() } else { "Comment Deleted" } } pub fn body_source(&self) -> Option<&str> { self.body_source.as_deref() } pub fn published(&self) -> Option> { self.published } pub fn updated(&self) -> Option> { self.updated } pub(crate) fn update<'a>(&self, state: &'a State) -> CommentChanges<'a> { CommentChanges { state, kind: CommentChangesKind::Update { id: self.id() }, body: None, body_source: None, published: self.published, updated: None, } } pub fn deleted(&self) -> bool { self.deleted } } impl<'a> CommentChanges<'a> { fn new( state: &'a State, profile_id: Uuid, submission_id: Uuid, comment_id: Option, ) -> Self { CommentChanges { state, kind: CommentChangesKind::Create { profile_id, submission_id, comment_id, }, body: None, body_source: None, published: None, updated: None, } } pub(crate) fn body(&mut self, body: &str) -> &mut Self { self.body = Some(html(body)); self } pub(crate) fn body_source(&mut self, body_source: &str) -> &mut Self { self.body_source = Some(body_source.to_owned()); self.body(&bbcode(body_source, |v| v)) } pub(crate) fn published(&mut self, published: DateTime) -> &mut Self { if self.published.is_none() { self.published = Some(published); } self } pub(crate) fn updated(&mut self, updated: DateTime) -> &mut Self { if self.published.is_some() { self.updated = Some(updated); } self } pub(crate) fn any_changes(&self) -> bool { self.body.is_some() || self.published.is_some() || self.updated.is_some() } pub(crate) fn save(self) -> Result { self.state.store.comments.save(&self) } } impl Store { pub(super) fn build(db: &Db) -> Result { Ok(Store { comment_tree: db.open_tree("profiles/comments")?, submission_tree: db.open_tree("profiles/comments/submissions")?, submission_created_tree: db.open_tree("profiles/comments/submissions/created")?, reply_created_tree: db.open_tree("/profiles/comments/reply/created")?, created_tree: db.open_tree("profiles/comments/created")?, count_tree: db.open_tree("profiles/comments/count")?, }) } pub(crate) fn create<'a>( &self, state: &'a State, submission_id: Uuid, profile_id: Uuid, comment_id: Option, ) -> CommentChanges<'a> { CommentChanges::new(state, submission_id, profile_id, comment_id) } fn save(&self, changes: &CommentChanges) -> Result { match &changes.kind { CommentChangesKind::Create { profile_id, submission_id, comment_id, } => { let id = self.do_create(*submission_id, *profile_id, *comment_id)?; self.do_update(id, changes) } CommentChangesKind::Update { id } => self.do_update(*id, changes), } } pub(crate) fn do_create( &self, submission_id: Uuid, profile_id: Uuid, comment_id: Option, ) -> Result { let mut id; let mut stored_comment; let now = Utc::now().into(); let mut stored_comment_vec = vec![]; while { stored_comment_vec.clear(); let cursor = Cursor::new(&mut stored_comment_vec); id = Uuid::new_v4(); stored_comment = StoredComment { id, submission_id, profile_id, comment_id, body: String::new(), body_source: None, published: None, updated: None, created_at: now, updated_at: now, deleted_at: None, }; serde_json::to_writer(cursor, &stored_comment)?; self.comment_tree .compare_and_swap( id_comment_key(id), None as Option<&[u8]>, Some(stored_comment_vec.as_slice()), )? .is_err() } {} let res = [ &self.submission_tree, &self.submission_created_tree, &self.reply_created_tree, &self.created_tree, &self.count_tree, ] .transaction(move |trees| { let submission_tree = &trees[0]; let submission_created_tree = &trees[1]; let reply_created_tree = &trees[2]; let created_tree = &trees[3]; let count_tree = &trees[4]; submission_tree.insert( submission_id_key(submission_id, id).as_bytes(), id.as_bytes(), )?; created_tree.insert(created_comment_key(now, id).as_bytes(), id.as_bytes())?; if let Some(comment_id) = comment_id { reply_created_tree.insert( reply_created_key(comment_id, now, id).as_bytes(), id.as_bytes(), )?; } else { submission_created_tree.insert( submission_id_created_comment_key(submission_id, now, id).as_bytes(), id.as_bytes(), )?; } super::count( count_tree, &submission_id_comment_count_key(submission_id), |c| c.saturating_add(1), )?; Ok(()) }); if let Err(e) = res { self.comment_tree.remove(id_comment_key(id))?; return Err(e.into()); } Ok(id) } pub fn count(&self, submission_id: Uuid) -> Result { match self .count_tree .get(submission_id_comment_count_key(submission_id))? { Some(ivec) => Ok(String::from_utf8_lossy(&ivec).parse::().unwrap_or(0)), None => Ok(0), } } fn do_update(&self, id: Uuid, changes: &CommentChanges) -> Result { let stored_comment_ivec = match self.comment_tree.get(id_comment_key(id))? { Some(ivec) => ivec, None => return Err(StoreError::Missing), }; let mut stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?; if let Some(updated) = changes.updated { if let Some(previously_updated) = stored_comment.updated.or_else(|| stored_comment.published) { if updated < previously_updated { return Err(StoreError::Outdated); } } } if let Some(body) = &changes.body { stored_comment.body = body.clone(); } if let Some(body_source) = &changes.body_source { stored_comment.body_source = Some(body_source.clone()); } if let Some(published) = changes.published { stored_comment.published = Some(published); } if let Some(updated) = changes.updated { stored_comment.updated = Some(updated); } stored_comment.updated_at = Utc::now().into(); let stored_comment_vec = serde_json::to_vec(&stored_comment)?; if self .comment_tree .compare_and_swap( id_comment_key(id), Some(&stored_comment_ivec), Some(stored_comment_vec.as_slice()), )? .is_err() { return Err(StoreError::DoubleStore); } Ok(stored_comment.into()) } pub fn replies_for(&self, comment_id: Uuid) -> impl DoubleEndedIterator { self.reply_created_tree .scan_prefix(reply_created_prefix(comment_id)) .values() .filter_map(|res| res.ok()) .filter_map(uuid_from_ivec) } pub fn for_submission(&self, submission_id: Uuid) -> impl DoubleEndedIterator { self.submission_created_tree .scan_prefix(submission_id_created_prefix(submission_id)) .values() .filter_map(|res| res.ok()) .filter_map(uuid_from_ivec) .rev() } fn date_range( &self, range: impl std::ops::RangeBounds, ) -> impl DoubleEndedIterator where K: AsRef<[u8]>, { self.submission_created_tree .range(range) .values() .filter_map(|res| res.ok()) .filter_map(uuid_from_ivec) } pub fn newer_than(&self, id: Uuid) -> impl DoubleEndedIterator { let this = self.clone(); self.comment_tree .get(id_comment_key(id)) .ok() .and_then(|opt| opt) .and_then(|stored_comment_ivec| { let stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec).ok()?; Some((stored_comment.submission_id, stored_comment.created_at)) }) .into_iter() .flat_map(move |(submission_id, created_at)| { let range_start = submission_id_created_comment_range_start(submission_id, created_at); let range_start = range_start.as_bytes().to_vec(); this.date_range(range_start..) }) } pub fn older_than(&self, id: Uuid) -> impl DoubleEndedIterator { let this = self.clone(); self.comment_tree .get(id_comment_key(id)) .ok() .and_then(|opt| opt) .and_then(|stored_comment_ivec| { let stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec).ok()?; Some((stored_comment.submission_id, stored_comment.created_at)) }) .into_iter() .flat_map(move |(submission_id, created_at)| { let range_end = submission_id_created_comment_range_start(submission_id, created_at); let range_end = range_end.as_bytes().to_vec(); this.date_range(..range_end) }) .rev() } pub fn by_id(&self, id: Uuid) -> Result, StoreError> { let stored_comment_ivec = match self.comment_tree.get(id_comment_key(id))? { Some(ivec) => ivec, None => return Ok(None), }; let stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?; Ok(Some(stored_comment.into())) } pub(crate) fn delete(&self, comment_id: Uuid) -> Result>, StoreError> { let stored_comment_ivec = match self.comment_tree.get(id_comment_key(comment_id))? { Some(ivec) => ivec, None => return Ok(None), }; let mut stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?; stored_comment.deleted_at = Some(Utc::now()); stored_comment.body = String::new(); let stored_comment_vec = serde_json::to_vec(&stored_comment)?; if self .comment_tree .compare_and_swap( id_comment_key(comment_id), Some(&stored_comment_ivec), Some(stored_comment_vec), )? .is_err() { return Err(StoreError::DoubleStore); } Ok(Some(Undo(stored_comment.into()))) } } // Maps id -> Comment fn id_comment_key(id: Uuid) -> String { format!("/comment/{}/data", id) } // Maps submission_id -> id fn submission_id_key(submission_id: Uuid, id: Uuid) -> String { format!("/submission/{}/comment/{}", submission_id, id) } fn submission_id_created_prefix(submission_id: Uuid) -> String { format!("/submission/{}/created", submission_id) } // Maps submission_id + created_at -> id fn submission_id_created_comment_key( submission_id: Uuid, created_at: DateTime, id: Uuid, ) -> String { format!( "/submission/{}/created/{}/comment/{}", submission_id, created_at.to_rfc3339(), id, ) } fn reply_created_key(reply_to_id: Uuid, created_at: DateTime, id: Uuid) -> String { format!( "/reply-to/{}/created/{}/comment/{}", reply_to_id, created_at.to_rfc3339(), id ) } fn reply_created_prefix(reply_to_id: Uuid) -> String { format!("/reply-to/{}/created", reply_to_id) } fn submission_id_created_comment_range_start( submission_id: Uuid, created_at: DateTime, ) -> String { format!( "/submission/{}/created/{}", submission_id, created_at.to_rfc3339() ) } // Maps created_at -> id fn created_comment_key(created_at: DateTime, id: Uuid) -> String { format!("/created/{}/comment/{}", created_at.to_rfc3339(), id) } // Maps submission -> comment count fn submission_id_comment_count_key(submission_id: Uuid) -> String { format!("/submission/{}/count", submission_id) } fn uuid_from_ivec(ivec: sled::IVec) -> Option { Uuid::from_slice(&ivec).ok() } impl From for Comment { fn from(sc: StoredComment) -> Self { Comment { id: sc.id, submission_id: sc.submission_id, profile_id: sc.profile_id, comment_id: sc.comment_id, body: sc.body, body_source: sc.body_source, published: sc.published, updated: sc.updated, deleted: sc.deleted_at.is_some(), } } }