use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{NativeId, Page, SourceError, Task};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Comment {
pub id: NativeId,
pub author: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub body: String,
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<CommentOrigin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CommentOrigin {
pub task: String,
pub id: NativeId,
pub author: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub url: Option<String>,
}
const MARKER_OPEN: &str = "<!-- onetaskgraph.comment_origin `";
const MARKER_CLOSE: &str = "` -->";
const MARKER_SEPARATOR: &str = "\n\n";
impl CommentOrigin {
#[must_use]
pub fn is_qualified_task(task: &str) -> bool {
task.split_once(':').is_some_and(|(source, native)| {
!native.is_empty() && crate::SourceName::new(source).is_ok()
})
}
pub fn qualified(&self) -> Result<&Self, SourceError> {
if Self::is_qualified_task(&self.task) {
Ok(self)
} else {
Err(SourceError::Malformed {
message: format!(
"a copied comment's origin names its task as {:?}, which is not a \
qualified task id `<source>:<native task id>`",
self.task
),
})
}
}
#[must_use]
pub fn mark(&self, body: &str) -> String {
let encoded = serde_json::to_string(self)
.expect("a comment origin serialises")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('`', "\\u0060");
format!("{body}{MARKER_SEPARATOR}{MARKER_OPEN}{encoded}{MARKER_CLOSE}")
}
}
impl CommentOrigin {
pub fn kept(held: &str, body: &CommentBody) -> Result<CommentBody, SourceError> {
let origin_of = |text: &str| {
Comment {
id: NativeId(String::new()),
author: None,
created_at: None,
updated_at: None,
body: text.to_owned(),
url: None,
origin: None,
}
.unmarked()
.origin
};
match (origin_of(held), origin_of(body.as_str())) {
(Some(origin), None) => CommentBody::new(origin.mark(body.as_str())),
_ => Ok(body.clone()),
}
}
}
impl Comment {
#[must_use]
pub fn unmarked(mut self) -> Self {
if let Some(origin) = &self.origin {
if !CommentOrigin::is_qualified_task(&origin.task) {
self.origin = None;
}
return self;
}
let trimmed = self.body.trim_end();
let Some(open) = trimmed.rfind(MARKER_OPEN) else {
return self;
};
let Some(encoded) = trimmed[open + MARKER_OPEN.len()..].strip_suffix(MARKER_CLOSE) else {
return self;
};
let Ok(origin) = serde_json::from_str::<CommentOrigin>(encoded) else {
return self;
};
if !CommentOrigin::is_qualified_task(&origin.task) {
return self;
}
let before = &self.body[..open];
let body = before.strip_suffix(MARKER_SEPARATOR).unwrap_or(before);
self.body = body.to_owned();
self.origin = Some(origin);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CommentCopy {
pub body: CommentBody,
pub origin: CommentOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct CommentBody(String);
impl CommentBody {
pub fn new(text: impl Into<String>) -> Result<Self, SourceError> {
let text = text.into();
if text.is_empty() {
return Err(SourceError::Refused {
message: "a comment body cannot be empty; write what the comment says".to_owned(),
});
}
Ok(Self(text))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for CommentBody {
type Error = String;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::new(text).map_err(|error| error.to_string())
}
}
impl From<CommentBody> for String {
fn from(body: CommentBody) -> Self {
body.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct NewComment {
pub body: CommentBody,
#[serde(default)]
pub author: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskDetailRead {
pub task: Task,
pub comments: Option<Result<Option<Page<Comment>>, SourceError>>,
}
#[must_use]
pub fn commentless(kind: &str) -> SourceError {
SourceError::Refused {
message: format!("the {kind} plugin has no comments"),
}
}