Add Spoiler Flag with veil, AP content-warning encoding, and full-stack plumbing

Authors can flag a review's comment as containing spoilers; the comment is
blurred behind a reveal control for every viewer except the author, omitted
from RSS, and federates as containsSpoilers plus an AP summary content
warning (ADR-0011) so Mastodon-class clients also collapse it. Flag follows
the general-edit path (ADR-0001) across domain, both DB backends, REST,
HTML templates, SPA (shadcn), TUI fixtures, and exports.
This commit is contained in:
2026-10-17 21:12:56 +02:00
parent 9dd9df1f73
commit bb2001b9cc
66 changed files with 513 additions and 167 deletions

View File

@@ -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()],

View File

@@ -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]

View File

@@ -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?;

View File

@@ -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();

View File

@@ -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,
}))
}

View File

@@ -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(),
})
}

View File

@@ -62,6 +62,7 @@ fn make_entry_full(
.and_hms_opt(0, 0, 0)
.unwrap(),
None,
false,
)
.unwrap();
DiaryEntry::new(movie, review)
@@ -81,7 +82,7 @@ async fn csv_has_header_and_one_row() {
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")
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id,spoilers\n")
);
assert!(text.contains("Inception"));
assert!(text.contains("2010"));
@@ -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"
);
}

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -22,9 +22,13 @@ 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()

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN spoilers INTEGER NOT NULL DEFAULT 0;

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -45,9 +45,17 @@
{% if let Some(wm) = entry.review().watch_medium() %}
<span class="watch-medium">{{ wm.label() }}</span>
{% endif %}
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
{% if let Some(comment) = entry.review().comment() %}
{% if !author && 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 } %}

View File

@@ -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>

View File

@@ -129,9 +129,17 @@
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
{% endfor %}
</div>
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
{% if let Some(comment) = entry.review().comment() %}
{% if !author && 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 } %}

View File

@@ -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">

View File

@@ -219,9 +219,17 @@
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
{% endfor %}
</div>
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
{% if let Some(comment) = entry.review().comment() %}
{% if !author && 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">