use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
pub id: String,
pub comment_type: CommentType,
pub block_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range: Option<TextRange>,
pub author: Collaborator,
pub created: DateTime<Utc>,
pub content: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub resolved: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_by: Option<Collaborator>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Comment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl Comment {
#[must_use]
pub fn new(
id: impl Into<String>,
block_ref: impl Into<String>,
author: Collaborator,
content: impl Into<String>,
) -> Self {
Self {
id: id.into(),
comment_type: CommentType::Comment,
block_ref: block_ref.into(),
range: None,
author,
created: Utc::now(),
content: content.into(),
resolved: false,
resolved_by: None,
resolved_at: None,
replies: Vec::new(),
parent_id: None,
priority: None,
tags: Vec::new(),
}
}
#[must_use]
pub fn highlight(
id: impl Into<String>,
block_ref: impl Into<String>,
range: TextRange,
author: Collaborator,
color: HighlightColor,
) -> Self {
Self {
id: id.into(),
comment_type: CommentType::Highlight { color },
block_ref: block_ref.into(),
range: Some(range),
author,
created: Utc::now(),
content: String::new(),
resolved: false,
resolved_by: None,
resolved_at: None,
replies: Vec::new(),
parent_id: None,
priority: None,
tags: Vec::new(),
}
}
#[must_use]
pub fn suggestion(
id: impl Into<String>,
block_ref: impl Into<String>,
range: TextRange,
author: Collaborator,
original: impl Into<String>,
suggested: impl Into<String>,
) -> Self {
Self {
id: id.into(),
comment_type: CommentType::Suggestion {
original: original.into(),
suggested: suggested.into(),
status: SuggestionStatus::Pending,
},
block_ref: block_ref.into(),
range: Some(range),
author,
created: Utc::now(),
content: String::new(),
resolved: false,
resolved_by: None,
resolved_at: None,
replies: Vec::new(),
parent_id: None,
priority: None,
tags: Vec::new(),
}
}
#[must_use]
pub fn reaction(
id: impl Into<String>,
block_ref: impl Into<String>,
author: Collaborator,
emoji: impl Into<String>,
) -> Self {
Self {
id: id.into(),
comment_type: CommentType::Reaction {
emoji: emoji.into(),
},
block_ref: block_ref.into(),
range: None,
author,
created: Utc::now(),
content: String::new(),
resolved: false,
resolved_by: None,
resolved_at: None,
replies: Vec::new(),
parent_id: None,
priority: None,
tags: Vec::new(),
}
}
#[must_use]
pub fn with_range(mut self, range: TextRange) -> Self {
self.range = Some(range);
self
}
#[must_use]
pub fn with_priority(mut self, priority: Priority) -> Self {
self.priority = Some(priority);
self
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn add_reply(&mut self, mut reply: Comment) {
reply.parent_id = Some(self.id.clone());
self.replies.push(reply);
}
pub fn resolve(&mut self, by: Collaborator) {
self.resolved = true;
self.resolved_by = Some(by);
self.resolved_at = Some(Utc::now());
}
pub fn unresolve(&mut self) {
self.resolved = false;
self.resolved_by = None;
self.resolved_at = None;
}
#[must_use]
pub fn is_suggestion(&self) -> bool {
matches!(self.comment_type, CommentType::Suggestion { .. })
}
#[must_use]
pub fn suggestion_status(&self) -> Option<SuggestionStatus> {
match &self.comment_type {
CommentType::Suggestion { status, .. } => Some(*status),
_ => None,
}
}
pub fn accept_suggestion(&mut self) -> bool {
if let CommentType::Suggestion { status, .. } = &mut self.comment_type {
*status = SuggestionStatus::Accepted;
true
} else {
false
}
}
pub fn reject_suggestion(&mut self) -> bool {
if let CommentType::Suggestion { status, .. } = &mut self.comment_type {
*status = SuggestionStatus::Rejected;
true
} else {
false
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum CommentType {
Comment,
Highlight {
color: HighlightColor,
},
Suggestion {
original: String,
suggested: String,
status: SuggestionStatus,
},
Reaction {
emoji: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum HighlightColor {
#[default]
Yellow,
Green,
Blue,
Pink,
Orange,
Purple,
Red,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum SuggestionStatus {
#[default]
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Priority {
Low,
Normal,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextRange {
pub start: usize,
pub end: usize,
}
impl TextRange {
#[must_use]
pub const fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
#[must_use]
pub const fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.start >= self.end
}
#[must_use]
pub const fn contains(&self, pos: usize) -> bool {
pos >= self.start && pos < self.end
}
#[must_use]
pub const fn overlaps(&self, other: &Self) -> bool {
self.start < other.end && other.start < self.end
}
#[must_use]
pub const fn contains_range(&self, other: &Self) -> bool {
self.start <= other.start && other.end <= self.end
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Collaborator {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl Collaborator {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
email: None,
avatar: None,
user_id: None,
color: None,
}
}
#[must_use]
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
#[must_use]
pub fn with_avatar(mut self, avatar: impl Into<String>) -> Self {
self.avatar = Some(avatar.into());
self
}
#[must_use]
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
#[must_use]
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = Some(color.into());
self
}
}