Implement video synopsis field in the backend

This commit is contained in:
Simon Repp
2025-07-28 11:47:55 +02:00
parent cbb9a6458a
commit eae09a97df
8 changed files with 146 additions and 16 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn edit_page(
let mut select_feeds = Select::new("feeds", translations.feeds);
let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms);
let mut select_video_order = Select::new("video_order", translations.video_order);
let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description);
let mut textarea_description = Textarea::markdown_subset(translations, "description", translations.description);
let t_disabled = translations.disabled;
let t_enabled = translations.enabled;
+1 -1
View File
@@ -87,7 +87,7 @@ pub fn edit_page(
let mut select_feeds = Select::new("feeds", translations.feeds);
let mut select_order = Select::new("order", translations.order);
let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms);
let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description);
let mut textarea_description = Textarea::markdown_subset(translations, "description", translations.description);
let t_disabled = translations.disabled;
let t_enabled = translations.enabled;
+7 -1
View File
@@ -34,6 +34,7 @@ pub struct UpdateFormFeedback<'a> {
pub platform_integration: Option<PlatformIntegration>,
pub release_date: Field,
pub sort_number: Field,
pub synopsis: Field,
pub title: Field,
pub unlisted: bool
}
@@ -79,7 +80,8 @@ pub fn edit_page(
let mut select_download = Select::new("download", translations.downloads);
let mut select_embedding = Select::new("embedding", translations.embedding);
let mut select_platform_integration = Select::new("platform_integration", translations.automatic_link_previews_and_content_embedding_on_platforms);
let mut textarea_description = Textarea::markdown_subset(language, "description", translations.description);
let mut textarea_description = Textarea::markdown_subset(translations, "description", translations.description);
let mut textarea_synopsis = Textarea::synopsis(translations, "synopsis", translations.synopsis);
let t_disabled = translations.disabled;
let t_enabled = translations.enabled;
@@ -131,6 +133,7 @@ pub fn edit_page(
platform_integration,
release_date,
sort_number,
synopsis,
title,
unlisted
}) = &form_feedback {
@@ -167,6 +170,7 @@ pub fn edit_page(
select_embedding.selected(embedding_value);
select_platform_integration.selected(platform_integration_value);
textarea_description.value(description);
textarea_synopsis.validated_value(synopsis);
format!(r#"<span class="feedback">{t_not_saved}</span>"#)
} else {
@@ -206,6 +210,7 @@ pub fn edit_page(
select_embedding.selected(embedding);
select_platform_integration.selected(platform_integration);
textarea_description.value_option(&video.description);
textarea_synopsis.value_option(&video.synopsis);
String::new()
};
@@ -247,6 +252,7 @@ pub fn edit_page(
<div class="form_group">
<form action="{update_action}" autocomplete="off" data-warn-discard method="post">
{input_title}
{textarea_synopsis}
{textarea_description}
<div class="form_split divide_3_1">
{input_release_date}
+15 -2
View File
@@ -18,10 +18,10 @@ use crate::{
};
use crate::editor::routes;
use crate::editor::widgets::not_found;
use crate::manifest::MAX_SYNOPSIS_CHARS;
use super::edit::edit_page;
use super::edit::UpdateFormFeedback;
use super::pending_manifest_errors;
#[derive(Deserialize)]
@@ -34,6 +34,7 @@ pub struct VideoUpdateForm {
platform_integration: String,
release_date: String,
sort_number: String,
synopsis: String,
title: String,
unlisted: Option<String>
}
@@ -91,12 +92,22 @@ pub async fn update(
)
};
let synopsis_trimmed = form.synopsis.trim();
let synopsis = if synopsis_trimmed.is_empty() {
None
} else if synopsis_trimmed.chars().count() <= MAX_SYNOPSIS_CHARS {
Some(Ok(synopsis_trimmed.to_string()))
} else {
Some(Err(language.translations.xxx_characters_maximum(MAX_SYNOPSIS_CHARS)))
};
let title_trimmed = form.title.trim();
let unlisted = form.unlisted.is_some();
if release_date.as_ref().is_some_and(|release_date| release_date.is_err()) ||
sort_number.as_ref().is_some_and(|sort_number| sort_number.is_err()) {
sort_number.as_ref().is_some_and(|sort_number| sort_number.is_err()) ||
synopsis.as_ref().is_some_and(|synopsis| synopsis.is_err()) {
let video = match context.get_video(&site_path) {
Some(video) => video,
None => return Either::Right(not_found(&context, &language, &site_path))
@@ -111,6 +122,7 @@ pub async fn update(
platform_integration,
release_date: Field::validated(release_date_trimmed, release_date.and_then(|release_date| release_date.err())),
sort_number: Field::validated(sort_number_trimmed, sort_number.and_then(|sort_number| sort_number.err())),
synopsis: Field::validated(synopsis_trimmed, synopsis.and_then(|synopsis| synopsis.err())),
title: Field::valid(title_trimmed),
unlisted
};
@@ -128,6 +140,7 @@ pub async fn update(
video_mut.platform_integration_local_set(platform_integration);
video_mut.release_date = release_date.and_then(|release_date| release_date.ok());
video_mut.sort_number = sort_number.and_then(|sort_number| sort_number.ok());
video_mut.synopsis = synopsis.and_then(|synopsis| synopsis.ok());
video_mut.title = if title_trimmed.is_empty() { None } else { Some(title_trimmed.to_owned()) };
video_mut.unlisted_local_set(unlisted);
video_mut.write_manifest(&context);
+79 -11
View File
@@ -5,14 +5,17 @@ use std::fmt::{Display, Formatter};
use indoc::formatdoc;
use crate::Language;
use crate::{Field, Translations};
use crate::manifest::MAX_SYNOPSIS_CHARS;
use crate::util::html_escape_outside_attribute;
pub struct Textarea<'a> {
hint: Option<&'a str>,
error: String,
hint: Option<String>,
id: Option<&'a str>,
key: &'a str,
label: &'a str,
limit: Option<usize>,
rows: usize,
value: String
}
@@ -25,31 +28,87 @@ impl<'a> Textarea<'a> {
/// Initialize a textarea for an image description
pub fn image_description(key: &'a str, label: &'a str) -> Textarea<'a> {
Textarea::new(None, key, label, 5)
Textarea::new(
None,
key,
label,
None,
5
)
}
/// Initialize a textarea for a manifest
pub fn manifest(key: &'a str, label: &'a str) -> Textarea<'a> {
Textarea::new(None, key, label, 16)
Textarea::new(
None,
key,
label,
None,
16
)
}
/// Initialize a textarea for markdown subset text
pub fn markdown_subset(language: &Language, key: &'a str, label: &'a str) -> Textarea<'a> {
let hint = Some(language.translations.markdown_inline_links_are_supported_standalone_urls_are_automatically_linked);
Textarea::new(hint, key, label, 8)
pub fn markdown_subset(
translations: &Translations,
key: &'a str,
label: &'a str
) -> Textarea<'a> {
let hint = Some(
translations.markdown_inline_links_are_supported_standalone_urls_are_automatically_linked.to_string()
);
Textarea::new(
hint,
key,
label,
None,
8
)
}
fn new(hint: Option<&'a str>, key: &'a str, label: &'a str, rows: usize) -> Textarea<'a> {
fn new(
hint: Option<String>,
key: &'a str,
label: &'a str,
limit: Option<usize>,
rows: usize
) -> Textarea<'a> {
Textarea {
error: String::new(),
hint,
id: None,
key,
label,
limit,
rows,
value: String::new()
}
}
/// Initialize a textarea for a synopsis text
pub fn synopsis(
translations: &Translations,
key: &'a str,
label: &'a str
) -> Textarea<'a> {
let hint = Some(translations.xxx_characters_maximum(MAX_SYNOPSIS_CHARS));
Textarea::new(
hint,
key,
label,
Some(MAX_SYNOPSIS_CHARS),
3
)
}
pub fn validated_value(&mut self, field: &Field) {
if let Some(error) = &field.error {
self.error = format!(r#"<span class="error">{error}</span>"#);
}
self.value(&field.value);
}
pub fn value(&mut self, value: &str) {
self.value = html_escape_outside_attribute(value);
}
@@ -63,17 +122,25 @@ impl<'a> Textarea<'a> {
impl Display for Textarea<'_> {
fn fmt(&self, formatter: &mut Formatter) -> std::fmt::Result {
let error = &self.error;
let key = self.key;
let id = if let Some(id) = self.id { id } else { self.key };
let label = self.label;
let rows = self.rows;
let value = &self.value;
let id = if let Some(id) = self.id { id } else { self.key };
let maxlength = if let Some(limit) = self.limit {
format!(r#"maxlength="{limit}""#)
} else {
String::new()
};
let textarea = formatdoc!(r#"
<textarea id="{id}" name="{key}" rows="{rows}">{value}</textarea>
<textarea id="{id}" {maxlength} name="{key}" rows="{rows}">{value}</textarea>
"#);
let textarea_wrapped = if let Some(hint) = self.hint {
let textarea_wrapped = if let Some(hint) = &self.hint {
formatdoc!(r#"
<div class="textarea_with_hint">
<span>{hint}</span>
@@ -88,6 +155,7 @@ impl Display for Textarea<'_> {
<div class="form_field">
<label for="{id}">{label}</label>
{textarea_wrapped}
{error}
</div>
"#);
+2
View File
@@ -5,6 +5,8 @@ use std::path::Path;
use enolib::{Attribute, SectionElement};
pub const MAX_SYNOPSIS_CHARS: usize = 256;
// TODO: Use attribute.snippet(), i.e. make this a attribute_error_with_snippet() (?) Need to make sure this nicely displays also in interface
pub fn attribute_error(
attribute: &Attribute,
+11
View File
@@ -225,6 +225,7 @@ pub struct Translations {
pub subscribe: &'static str,
pub subscribe_permalink: &'static str,
pub subtitles: &'static str,
pub synopsis: &'static str,
test_file_successfully_uploaded_but_remote_verification_failed_message: &'static str,
pub the_link_requested_for_deletion_was_not_found: &'static str,
pub the_poster_to_be_updated_was_not_found: &'static str,
@@ -276,6 +277,7 @@ pub struct Translations {
pub warning: &'static str,
pub website: &'static str,
pub worst: &'static str,
xxx_characters_maximum: &'static str,
pub you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided: &'static str
}
@@ -508,6 +510,7 @@ impl Translations {
subscribe: "Abonnieren",
subscribe_permalink: "abonnieren",
subtitles: "Untertitel",
synopsis: "Kurzbeschreibung",
test_file_successfully_uploaded_but_remote_verification_failed_message: indoc!("
Eine Testdatei wurde erfolgreich an der Adresse {remote_path} am Server hochgeladen, über die designierte öffentliche Adresse {public_address} konnte ihr Vorhandensein allerdings nicht verifiziert werden.
Bitte überprüfe ob das Serververzeichnis in den Seiteneinstellungen korrekt konfiguriert ist und ob deine (Sub)domain auch in deinen Hostingprovider-Einstellungen auf dieses selbe Verzeichnis verweist.
@@ -564,6 +567,7 @@ impl Translations {
warning: "Warnung",
website: "Website",
worst: "Schlechteste",
xxx_characters_maximum: "Maximal {maximum} Zeichen",
you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided: "Du musst nicht sowohl einen Titel als auch einen Permalink angeben, aber eines von beiden muss ausgefüllt sein."
}
}
@@ -796,6 +800,7 @@ impl Translations {
subscribe: "Subscribe",
subscribe_permalink: "subscribe",
subtitles: "Subtitles",
synopsis: "Synopsis",
test_file_successfully_uploaded_but_remote_verification_failed_message: indoc!("
A test file was successfully uploaded to the remote server directory at {remote_path} but verifying its presence at the designated public address {public_address} failed.
Please review if the remote server directory is correctly configured in your site settings and whether your (sub)domain is pointing to that same
@@ -853,6 +858,7 @@ impl Translations {
warning: "Warning",
website: "Website",
worst: "Worst",
xxx_characters_maximum: "{maximum} characters maximum",
you_need_not_provide_both_a_title_and_permalink_but_one_of_the_two_must_be_provided: "You need not provide both a title and permalink, but one of the two must be provided."
}
}
@@ -946,6 +952,10 @@ impl Translations {
pub fn video_player_widget_for_xxx(&self, title: &str) -> String {
self.video_player_widget_for_xxx.replace("{title}", title)
}
pub fn xxx_characters_maximum(&self, maximum: usize) -> String {
self.xxx_characters_maximum.replace("{maximum}", &maximum.to_string())
}
}
#[test]
@@ -978,6 +988,7 @@ fn check_translations() {
assert!(&translations.updating_video_from_editor_currently_not_allowed.contains("{video_title}"));
assert!(&translations.updating_video_from_editor_currently_not_allowed.contains("{manifest_path}"));
assert!(&translations.video_player_widget_for_xxx.contains("{title}"));
assert!(&translations.xxx_characters_maximum.contains("{maximum}"));
let disallowed_char = |c: char| !c.is_ascii_alphanumeric() && c != '-';
+30
View File
@@ -25,6 +25,7 @@ use crate::{
VideoMetaCached
};
use language_tags::LanguageTag;
use crate::manifest::MAX_SYNOPSIS_CHARS;
use crate::manifest::{attribute_error, element_error, not_supported_error};
use crate::util::{
checked_remove_dir_all,
@@ -44,6 +45,7 @@ const VIDEO_OPTIONS: &[&str] = &[
"release_date",
"sort_number",
"subtitles",
"synopsis",
"title",
"unlisted"
];
@@ -74,6 +76,7 @@ pub struct Video {
pub site_path: SitePath,
pub sort_number: Option<i32>,
pub subtitles: Vec<SubtitleFile>,
pub synopsis: Option<String>,
pub title: Option<String>,
/// Unlisted videos are not linked from the site anywhere, one needs to
/// know the link to access them. This property propagates downwards in
@@ -551,6 +554,27 @@ impl Video {
let error = element_error(element, manifest_path, message);
self.manifest_errors.push(error);
}
"synopsis" => {
if let Ok(embed) = element.as_embed() {
if let Some(value) = embed.value() {
let synopsis_chars = value.chars().count();
if synopsis_chars <= MAX_SYNOPSIS_CHARS {
self.synopsis = Some(value.to_string());
} else {
let message = format!("Synopsis is too long ({synopsis_chars}/{MAX_SYNOPSIS_CHARS} characters)");
let error = element_error(element, manifest_path, &message);
self.manifest_errors.push(error);
}
} else {
self.synopsis = None;
}
} else {
let message = "The 'synopsis' option needs to be provided as an embed, e.g.:\n-- synopsis\nA synopsis text\n--synopsis";
let error = element_error(element, manifest_path, message);
self.manifest_errors.push(error);
}
}
"title" => 'title: {
if let Ok(field) = element.as_field() {
if let Ok(result) = field.value() {
@@ -851,6 +875,7 @@ impl Video {
site_path,
sort_number: None,
subtitles: Vec::new(),
synopsis: None,
title: None,
unlisted: false,
versions: Vec::new()
@@ -1200,6 +1225,11 @@ impl Video {
eno.push_str(&format!("-- description\n{}\n-- description\n", description));
}
if let Some(synopsis) = &self.synopsis {
if !eno.is_empty() { eno.push('\n'); }
eno.push_str(&format!("-- synopsis\n{}\n-- synopsis\n", synopsis));
}
let path = context.site_dir.join(self.site_path.filesystem_relative()).join("video.eno");
fs::write(path, eno).unwrap();
}