@@ -60,6 +60,12 @@ pub struct ReviewObject {
|
||||
/// Discriminator so Movies Diary instances detect this as a review Note.
|
||||
#[serde(default)]
|
||||
pub(crate) review: bool,
|
||||
/// Spoiler Flag. Movies-Diary peers veil the comment behind this boolean;
|
||||
/// other fediverse apps render `summary` as a content warning (ADR-0011).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub(crate) contains_spoilers: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) attachment: Vec<ApAttachment>,
|
||||
#[serde(default)]
|
||||
@@ -98,6 +104,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
String::new()
|
||||
};
|
||||
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
|
||||
let spoiler_flag = review.spoilers() && comment_text.is_some();
|
||||
let content = match &comment_text {
|
||||
Some(c) => format!(
|
||||
"{} {}{}\n{}\n{}",
|
||||
@@ -145,6 +152,8 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
|
||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||
review: true,
|
||||
contains_spoilers: spoiler_flag,
|
||||
summary: spoiler_flag.then(|| "Contains spoilers".to_string()),
|
||||
attachment,
|
||||
tag,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct RemoteReviewUpdate<'a> {
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub poster_url: Option<&'a str>,
|
||||
pub watch_medium: Option<&'a str>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -157,6 +157,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
actor_url: actor_url_str,
|
||||
},
|
||||
watch_medium,
|
||||
spoilers: obj.contains_spoilers,
|
||||
});
|
||||
|
||||
self.review_store
|
||||
@@ -214,6 +215,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
watched_at: obj.watched_at.naive_utc(),
|
||||
poster_url: obj.poster_url.as_deref(),
|
||||
watch_medium: obj.watch_medium.as_deref(),
|
||||
spoilers: obj.contains_spoilers,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ fn review_to_ap_object_includes_two_hashtags() {
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
@@ -69,6 +70,7 @@ fn review_to_ap_object_has_public_addressing() {
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = review_to_ap_object(
|
||||
@@ -87,6 +89,74 @@ fn review_to_ap_object_has_public_addressing() {
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_carries_spoiler_flag_and_summary() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: Some(Comment::new("the mentor is the villain".into()).unwrap()),
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(obj.contains_spoilers);
|
||||
assert_eq!(obj.summary.as_deref(), Some("Contains spoilers"));
|
||||
let json = serde_json::to_value(&obj).unwrap();
|
||||
assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true));
|
||||
assert_eq!(json["summary"], "Contains spoilers");
|
||||
|
||||
// A flag without a comment never travels: there is nothing to veil.
|
||||
let review_no_comment = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: None,
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj2 = review_to_ap_object(
|
||||
&review_no_comment,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/2".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(!obj2.contains_spoilers);
|
||||
assert!(obj2.summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchlist_to_ap_object_has_public_addressing() {
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn review_row_to_domain(
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
|
||||
@@ -112,6 +113,7 @@ pub fn review_row_to_domain(
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ fn stream_csv(
|
||||
use futures::StreamExt;
|
||||
let header = futures::stream::once(async {
|
||||
Ok(Bytes::from_static(
|
||||
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
|
||||
b"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n",
|
||||
))
|
||||
});
|
||||
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
|
||||
@@ -80,9 +80,10 @@ fn csv_row(e: &DiaryEntry) -> String {
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string())
|
||||
.unwrap_or_default();
|
||||
let spoilers = e.review().spoilers();
|
||||
format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
"{},{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id, spoilers
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
|
||||
"comment": e.review().comment().map(|c| c.value().to_string()),
|
||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
|
||||
"spoilers": e.review().spoilers(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ fn make_entry_full(
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
DiaryEntry::new(movie, review)
|
||||
@@ -80,9 +81,9 @@ async fn csv_has_header_and_one_row() {
|
||||
let bytes =
|
||||
collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
|
||||
);
|
||||
assert!(text.starts_with(
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n"
|
||||
));
|
||||
assert!(text.contains("Inception"));
|
||||
assert!(text.contains("2010"));
|
||||
assert!(text.contains("Christopher Nolan"));
|
||||
@@ -149,7 +150,7 @@ async fn empty_entries_returns_csv_header_only() {
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id\n"
|
||||
"title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) ON CONFLICT DO NOTHING",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -58,6 +58,7 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -75,13 +76,14 @@ impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
|
||||
WHERE ap_id = $5 AND remote_actor_url = $6",
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5
|
||||
WHERE ap_id = $6 AND remote_actor_url = $7",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.spoilers)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -69,6 +69,7 @@ struct ReviewRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ struct DiaryRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -139,6 +142,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -226,7 +230,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -256,7 +261,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||
@@ -276,7 +282,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -56,7 +56,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
@@ -90,7 +91,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1
|
||||
@@ -178,7 +180,8 @@ impl PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||
@@ -336,6 +339,7 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium,
|
||||
r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
@@ -412,7 +416,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -434,7 +439,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -461,7 +467,8 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
r.watch_medium,
|
||||
r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -521,6 +528,7 @@ impl DiaryQuery for PostgresDiaryRepository {
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
r.watch_medium,
|
||||
r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) struct ReviewRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -91,6 +92,7 @@ impl ReviewRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +114,7 @@ pub(crate) struct DiaryRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -134,6 +137,7 @@ impl DiaryRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
@@ -156,6 +160,7 @@ pub(crate) struct FeedRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
@@ -179,6 +184,7 @@ impl FeedRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
|
||||
@@ -36,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -48,6 +48,7 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
@@ -62,7 +63,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
@@ -81,12 +83,13 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 WHERE id = $6",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -112,7 +115,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
watch_medium,
|
||||
spoilers
|
||||
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
|
||||
@@ -22,9 +22,14 @@ impl RssFeedRenderer for RssAdapter {
|
||||
e.movie().title().value(),
|
||||
e.movie().release_year().value()
|
||||
);
|
||||
let description = match e.review().comment() {
|
||||
Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()),
|
||||
None => format!("{}/5", e.review().rating().value()),
|
||||
let description = match (e.review().comment(), e.review().spoilers()) {
|
||||
(Some(_), true) => {
|
||||
format!("{}/5 — contains spoilers", e.review().rating().value())
|
||||
}
|
||||
(Some(c), false) => {
|
||||
format!("{}/5 — {}", e.review().rating().value(), c.value())
|
||||
}
|
||||
(None, _) => format!("{}/5", e.review().rating().value()),
|
||||
};
|
||||
let pub_date = e
|
||||
.review()
|
||||
|
||||
@@ -46,8 +46,8 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -58,6 +58,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -75,13 +76,14 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ?
|
||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.spoilers)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -69,6 +69,7 @@ struct ReviewRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ struct DiaryRow {
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -139,6 +142,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
@@ -212,7 +216,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
@@ -238,7 +242,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
@@ -254,7 +258,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reviews ADD COLUMN spoilers INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -52,7 +52,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
@@ -82,7 +82,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ?
|
||||
@@ -158,7 +158,7 @@ impl SqliteDiaryRepository {
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?{remote_clause}{search_clause}
|
||||
@@ -305,7 +305,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let select_sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
@@ -378,7 +378,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
@@ -396,7 +396,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
@@ -419,7 +419,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
Box::pin(async_stream::stream! {
|
||||
let mut rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ?
|
||||
@@ -476,7 +476,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
|
||||
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium, r.spoilers,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) struct ReviewRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
@@ -95,6 +96,7 @@ impl ReviewRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ pub(crate) struct DiaryRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
@@ -138,6 +141,7 @@ impl DiaryRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
@@ -189,6 +193,7 @@ pub(crate) struct FeedRow {
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub spoilers: bool,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
@@ -212,6 +217,7 @@ impl FeedRow {
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
self.spoilers,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
|
||||
@@ -36,8 +36,8 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -48,6 +48,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
@@ -58,7 +59,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
@@ -77,12 +78,13 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ? WHERE id = ?",
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -104,7 +106,7 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
|
||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers
|
||||
FROM reviews WHERE user_id = ? ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
|
||||
@@ -46,8 +46,15 @@
|
||||
<span class="watch-medium">{{ wm.label() }}</span>
|
||||
{% endif %}
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="feed-meta">
|
||||
{% match entry.review().source() %}
|
||||
{% when ReviewSource::Remote with { actor_url } %}
|
||||
|
||||
@@ -152,8 +152,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="embed-comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="embed-comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="embed-watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -130,8 +130,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="feed-meta">
|
||||
{% match entry.review().source() %}
|
||||
{% when ReviewSource::Remote with { actor_url } %}
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
Comment<br>
|
||||
<textarea name="comment"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Contains spoilers <input type="checkbox" name="spoilers" value="true">
|
||||
</label>
|
||||
<label>
|
||||
Watched via <span class="optional">(optional)</span><br>
|
||||
<select name="watch_medium">
|
||||
|
||||
@@ -220,8 +220,15 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(comment) = entry.review().comment() %}
|
||||
{% if entry.review().spoilers() %}
|
||||
<details class="comment spoiler">
|
||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||
{{ comment.value() }}
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="comment">{{ comment.value() }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct LogReviewRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -75,6 +77,8 @@ pub struct EditReviewRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<Option<String>>)]
|
||||
pub watch_medium: Option<Option<domain::value_objects::WatchMedium>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_export_format() -> String {
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct ReviewDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -112,6 +114,7 @@ pub struct MovieStatsDto {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct SocialReviewDto {
|
||||
pub user_display: String,
|
||||
pub user_id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
@@ -119,6 +122,8 @@ pub struct SocialReviewDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct LogReviewCommand {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
@@ -31,6 +32,7 @@ pub struct EditReviewCommand {
|
||||
pub comment: Option<Option<String>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -34,6 +34,7 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<()
|
||||
.transpose()?,
|
||||
watched_at: cmd.watched_at,
|
||||
watch_medium: cmd.watch_medium,
|
||||
spoilers: cmd.spoilers,
|
||||
});
|
||||
|
||||
deps.review.update_review(&updated).await?;
|
||||
|
||||
@@ -73,6 +73,7 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
comment,
|
||||
cmd.watched_at,
|
||||
cmd.watch_medium,
|
||||
cmd.spoilers,
|
||||
)?;
|
||||
self.review_repo.save_review(&review).await?;
|
||||
let review_event = DomainEvent::ReviewLogged {
|
||||
|
||||
@@ -33,6 +33,7 @@ fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
||||
None,
|
||||
Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ fn make_review(user_id: UserId) -> Review {
|
||||
Some(Comment::new("original comment".into()).unwrap()),
|
||||
Utc::now().naive_utc(),
|
||||
Some(WatchMedium::Streaming),
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -62,6 +63,7 @@ async fn edit_own_review_updates_rating() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -87,6 +89,7 @@ async fn edit_nonexistent_review_returns_not_found() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -108,6 +111,7 @@ async fn edit_other_users_review_returns_forbidden() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -129,6 +133,7 @@ async fn edit_publishes_review_updated_event() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: None,
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -155,6 +160,7 @@ async fn edit_sets_watch_medium() {
|
||||
comment: None,
|
||||
watched_at: None,
|
||||
watch_medium: Some(Some(WatchMedium::Cinema)),
|
||||
spoilers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn user_feed_carries_author_and_entries() {
|
||||
None,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
diary.set_diary_page(Paginated {
|
||||
|
||||
@@ -67,6 +67,7 @@ async fn test_log_review_creates_movie_and_review() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -100,6 +101,7 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
@@ -122,6 +124,7 @@ async fn test_log_review_with_invalid_rating_fails() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let result = log_review::execute(&logger, cmd).await;
|
||||
assert!(result.is_err(), "rating > 5 should fail");
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn logs_review_with_manual_movie() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -106,6 +107,7 @@ async fn removes_from_watchlist_on_review() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -144,6 +146,7 @@ async fn logs_review_with_existing_movie_by_id() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -173,6 +176,7 @@ async fn existing_movie_not_found_returns_error() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
assert!(logger.log_review(cmd).await.is_err());
|
||||
@@ -199,6 +203,7 @@ async fn invalid_rating_returns_error() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
let result = logger.log_review(cmd).await;
|
||||
@@ -229,6 +234,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
@@ -296,6 +302,7 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
@@ -119,5 +119,6 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
|
||||
comment: row.comment.clone(),
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub async fn execute(
|
||||
comment: c.comment,
|
||||
watched_at: *event.watched_at(),
|
||||
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||
spoilers: false,
|
||||
};
|
||||
|
||||
deps.review_logger.log_review(review_cmd).await?;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct PersistedReview {
|
||||
pub created_at: NaiveDateTime,
|
||||
pub source: ReviewSource,
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -34,6 +35,7 @@ pub struct ReviewEdit {
|
||||
pub comment: Option<Option<Comment>>,
|
||||
pub watched_at: Option<NaiveDateTime>,
|
||||
pub watch_medium: Option<Option<WatchMedium>>,
|
||||
pub spoilers: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -47,6 +49,7 @@ pub struct Review {
|
||||
created_at: NaiveDateTime,
|
||||
source: ReviewSource,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
spoilers: bool,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
@@ -57,6 +60,7 @@ impl Review {
|
||||
comment: Option<Comment>,
|
||||
watched_at: NaiveDateTime,
|
||||
watch_medium: Option<WatchMedium>,
|
||||
spoilers: bool,
|
||||
) -> Result<Self, DomainError> {
|
||||
Ok(Self {
|
||||
id: ReviewId::generate(),
|
||||
@@ -68,6 +72,7 @@ impl Review {
|
||||
created_at: Utc::now().naive_utc(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium,
|
||||
spoilers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +87,7 @@ impl Review {
|
||||
created_at: row.created_at,
|
||||
source: row.source,
|
||||
watch_medium: row.watch_medium,
|
||||
spoilers: row.spoilers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +108,7 @@ impl Review {
|
||||
Some(wm) => wm,
|
||||
None => self.watch_medium,
|
||||
},
|
||||
spoilers: edit.spoilers.unwrap_or(self.spoilers),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +139,9 @@ impl Review {
|
||||
pub fn watch_medium(&self) -> Option<&WatchMedium> {
|
||||
self.watch_medium.as_ref()
|
||||
}
|
||||
pub fn spoilers(&self) -> bool {
|
||||
self.spoilers
|
||||
}
|
||||
pub fn stars(&self) -> [bool; 5] {
|
||||
let r = self.rating.value();
|
||||
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
|
||||
|
||||
@@ -79,6 +79,7 @@ fn make_review() -> Review {
|
||||
Some(Comment::new("great".into()).unwrap()),
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -114,10 +115,12 @@ fn review_from_persistence() {
|
||||
actor_url: "https://example.com/actor".into(),
|
||||
},
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
assert_eq!(*r.id(), id);
|
||||
assert!(r.is_remote());
|
||||
assert_eq!(r.comment(), None);
|
||||
assert!(r.spoilers());
|
||||
}
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ fn make_review(watch_medium: Option<WatchMedium>) -> Review {
|
||||
Some(Comment::new("great film".into()).unwrap()),
|
||||
NaiveDateTime::parse_from_str("2024-06-15 20:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
||||
watch_medium,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@@ -71,6 +72,23 @@ fn apply_edit_clears_watch_medium() {
|
||||
assert_eq!(edited.watch_medium(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_toggles_spoilers() {
|
||||
let original = make_review(None);
|
||||
let flag_on = original.apply_edit(ReviewEdit {
|
||||
spoilers: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(flag_on.spoilers());
|
||||
let flag_off = flag_on.apply_edit(ReviewEdit {
|
||||
spoilers: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!flag_off.spoilers());
|
||||
let untouched = original.apply_edit(ReviewEdit::default());
|
||||
assert!(!untouched.spoilers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_edit_no_changes_preserves_all() {
|
||||
let original = make_review(Some(WatchMedium::TV));
|
||||
|
||||
@@ -29,6 +29,7 @@ fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime)
|
||||
None,
|
||||
watched_at,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct LogReviewForm {
|
||||
pub watched_at: String,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub watch_medium: Option<String>,
|
||||
#[serde(default)]
|
||||
pub spoilers: bool,
|
||||
#[serde(rename = "_csrf", default)]
|
||||
pub csrf_token: String,
|
||||
}
|
||||
@@ -173,6 +175,7 @@ pub struct LogReviewData {
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<domain::value_objects::WatchMedium>,
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -212,6 +215,7 @@ impl TryFrom<LogReviewForm> for LogReviewData {
|
||||
comment: form.comment,
|
||||
watched_at,
|
||||
watch_medium,
|
||||
spoilers: form.spoilers,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -230,6 +234,7 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -249,6 +254,7 @@ impl LogReviewData {
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
watch_medium: self.watch_medium,
|
||||
spoilers: self.spoilers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn patch_review(
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
};
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
|
||||
@@ -168,11 +168,13 @@ pub async fn get_movie_detail(
|
||||
.iter()
|
||||
.map(|e| SocialReviewDto {
|
||||
user_display: e.user_display_name().to_string(),
|
||||
user_id: e.review().user_id().value(),
|
||||
rating: e.review().rating().value(),
|
||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
|
||||
is_federated: e.review().is_remote(),
|
||||
watch_medium: e.review().watch_medium().copied(),
|
||||
spoilers: e.review().spoilers(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: result.reviews.total_count,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn review_to_dto(review: &Review) -> ReviewDto {
|
||||
comment: review.comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(review.watched_at()),
|
||||
watch_medium: review.watch_medium().copied(),
|
||||
spoilers: review.spoilers(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ fn make_form(watched_at: &str) -> LogReviewForm {
|
||||
comment: None,
|
||||
watched_at: watched_at.to_string(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
csrf_token: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -24,6 +25,7 @@ fn make_request(watched_at: &str) -> LogReviewRequest {
|
||||
comment: None,
|
||||
watched_at: watched_at.to_string(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn parse_csv(content: &str) -> Vec<ParsedRow> {
|
||||
},
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -829,6 +830,7 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
|
||||
comment,
|
||||
watched_at,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
app.loading = true;
|
||||
return vec![Command::CreateReview(req)];
|
||||
|
||||
@@ -55,6 +55,7 @@ fn diary_entry() -> DiaryEntryDto {
|
||||
comment: None,
|
||||
watched_at: "1999-03-31T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -383,6 +384,7 @@ fn bulk_import_all_with_valid_rows_returns_import_next_command() {
|
||||
comment: None,
|
||||
watched_at: "1999-03-31T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
@@ -406,6 +408,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-01T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
LogReviewRequest {
|
||||
external_metadata_id: None,
|
||||
@@ -416,6 +419,7 @@ fn bulk_item_done_advances_stage_and_returns_next_command() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-02T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
},
|
||||
];
|
||||
m.bulk_import.results = vec![None, None];
|
||||
@@ -445,6 +449,7 @@ fn bulk_item_done_last_item_transitions_to_done() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-01T00:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
}];
|
||||
m.bulk_import.results = vec![None];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn log_review_request_skips_none_fields() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("external_metadata_id"));
|
||||
@@ -44,6 +45,7 @@ fn log_review_request_includes_director_when_set() {
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"manual_director\":\"Denis Villeneuve\""));
|
||||
|
||||
Reference in New Issue
Block a user