diff --git a/src/editor/endpoints/collection/edit.rs b/src/editor/endpoints/collection/edit.rs index d0d23b3..55746f8 100644 --- a/src/editor/endpoints/collection/edit.rs +++ b/src/editor/endpoints/collection/edit.rs @@ -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; diff --git a/src/editor/endpoints/playlist/edit.rs b/src/editor/endpoints/playlist/edit.rs index da30bb8..21f1397 100644 --- a/src/editor/endpoints/playlist/edit.rs +++ b/src/editor/endpoints/playlist/edit.rs @@ -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; diff --git a/src/editor/endpoints/video/edit.rs b/src/editor/endpoints/video/edit.rs index 15bae01..b23b79f 100644 --- a/src/editor/endpoints/video/edit.rs +++ b/src/editor/endpoints/video/edit.rs @@ -34,6 +34,7 @@ pub struct UpdateFormFeedback<'a> { pub platform_integration: Option, 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#"{t_not_saved}"#) } 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(
{input_title} + {textarea_synopsis} {textarea_description}
{input_release_date} diff --git a/src/editor/endpoints/video/update.rs b/src/editor/endpoints/video/update.rs index 73b395c..43098ae 100644 --- a/src/editor/endpoints/video/update.rs +++ b/src/editor/endpoints/video/update.rs @@ -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 } @@ -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); diff --git a/src/editor/textarea.rs b/src/editor/textarea.rs index 97f1e3e..fb73708 100644 --- a/src/editor/textarea.rs +++ b/src/editor/textarea.rs @@ -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, id: Option<&'a str>, key: &'a str, label: &'a str, + limit: Option, 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, + key: &'a str, + label: &'a str, + limit: Option, + 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#"{error}"#); + } + + 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#" - + "#); - let textarea_wrapped = if let Some(hint) = self.hint { + let textarea_wrapped = if let Some(hint) = &self.hint { formatdoc!(r#"
{hint} @@ -88,6 +155,7 @@ impl Display for Textarea<'_> {
{textarea_wrapped} + {error}
"#); diff --git a/src/manifest.rs b/src/manifest.rs index 261e7ed..d7b6d4c 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -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, diff --git a/src/translations.rs b/src/translations.rs index 5f37137..e278bb6 100644 --- a/src/translations.rs +++ b/src/translations.rs @@ -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 != '-'; diff --git a/src/video.rs b/src/video.rs index 63fc418..3421d83 100644 --- a/src/video.rs +++ b/src/video.rs @@ -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, pub subtitles: Vec, + pub synopsis: Option, pub title: Option, /// 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(); }