mirror of
https://codeberg.org/simonrepp/hyper8.git
synced 2026-08-14 13:45:27 +02:00
Implement video synopsis field in the backend
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
"#);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != '-';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user