558 lines
16 KiB
Rust
558 lines
16 KiB
Rust
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<Uuid>,
|
|
body: String,
|
|
body_source: Option<String>,
|
|
published: Option<DateTime<Utc>>,
|
|
updated: Option<DateTime<Utc>>,
|
|
deleted: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum CommentChangesKind {
|
|
Create {
|
|
profile_id: Uuid,
|
|
submission_id: Uuid,
|
|
comment_id: Option<Uuid>,
|
|
},
|
|
Update {
|
|
id: Uuid,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CommentChanges<'a> {
|
|
state: &'a State,
|
|
kind: CommentChangesKind,
|
|
body: Option<String>,
|
|
body_source: Option<String>,
|
|
published: Option<DateTime<Utc>>,
|
|
updated: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[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<Uuid>,
|
|
body: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
body_source: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
published: Option<DateTime<Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
updated: Option<DateTime<Utc>>,
|
|
created_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
deleted_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
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<Uuid> {
|
|
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<DateTime<Utc>> {
|
|
self.published
|
|
}
|
|
|
|
pub fn updated(&self) -> Option<DateTime<Utc>> {
|
|
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<Uuid>,
|
|
) -> 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<Utc>) -> &mut Self {
|
|
if self.published.is_none() {
|
|
self.published = Some(published);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub(crate) fn updated(&mut self, updated: DateTime<Utc>) -> &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<Comment, StoreError> {
|
|
self.state.store.comments.save(&self)
|
|
}
|
|
}
|
|
|
|
impl Store {
|
|
pub(super) fn build(db: &Db) -> Result<Self, sled::Error> {
|
|
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<Uuid>,
|
|
) -> CommentChanges<'a> {
|
|
CommentChanges::new(state, submission_id, profile_id, comment_id)
|
|
}
|
|
|
|
fn save(&self, changes: &CommentChanges) -> Result<Comment, StoreError> {
|
|
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<Uuid>,
|
|
) -> Result<Uuid, StoreError> {
|
|
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<u64, StoreError> {
|
|
match self
|
|
.count_tree
|
|
.get(submission_id_comment_count_key(submission_id))?
|
|
{
|
|
Some(ivec) => Ok(String::from_utf8_lossy(&ivec).parse::<u64>().unwrap_or(0)),
|
|
None => Ok(0),
|
|
}
|
|
}
|
|
|
|
fn do_update(&self, id: Uuid, changes: &CommentChanges) -> Result<Comment, StoreError> {
|
|
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<Item = Uuid> {
|
|
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<Item = Uuid> {
|
|
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<K>(
|
|
&self,
|
|
range: impl std::ops::RangeBounds<K>,
|
|
) -> impl DoubleEndedIterator<Item = Uuid>
|
|
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<Item = Uuid> {
|
|
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<Item = Uuid> {
|
|
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<Option<Comment>, 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<Option<Undo<Comment>>, 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<Utc>,
|
|
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<Utc>, 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<Utc>,
|
|
) -> String {
|
|
format!(
|
|
"/submission/{}/created/{}",
|
|
submission_id,
|
|
created_at.to_rfc3339()
|
|
)
|
|
}
|
|
|
|
// Maps created_at -> id
|
|
fn created_comment_key(created_at: DateTime<Utc>, 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> {
|
|
Uuid::from_slice(&ivec).ok()
|
|
}
|
|
|
|
impl From<StoredComment> 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(),
|
|
}
|
|
}
|
|
}
|