Add sidenotes

This commit is contained in:
Adrian Woźniak 2024-11-19 11:05:12 +01:00
parent 1171675ee3
commit b84e09ed28
18 changed files with 119 additions and 42 deletions

View File

@ -22,3 +22,6 @@ pub fn is_checked<'s>((id, slice): &(i32, &[String])) -> ::askama::Result<bool>
.filter_map(|s| s.parse::<i32>().ok()) .filter_map(|s| s.parse::<i32>().ok())
.any(|s| s == *id)) .any(|s| s == *id))
} }
pub fn some_or_default<'s>(s: &Option<String>) -> ::askama::Result<String> {
Ok(s.as_ref().cloned().unwrap_or_default())
}

View File

@ -1,4 +1,4 @@
#![feature(iterator_try_collect, duration_constructors)] #![feature(iterator_try_collect, duration_constructors, string_remove_matches)]
use actix::SyncArbiter; use actix::SyncArbiter;
use actix_files::Files; use actix_files::Files;
@ -7,7 +7,7 @@ use actix_session::{storage::RedisSessionStore, SessionMiddleware};
use actix_web::cookie::Key; use actix_web::cookie::Key;
use actix_web::{error, HttpResponse}; use actix_web::{error, HttpResponse};
use actix_web::{web::Data, App, HttpServer}; use actix_web::{web::Data, App, HttpServer};
use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder}; use actix_web_prom::PrometheusMetricsBuilder;
use sea_orm::Database; use sea_orm::Database;
use serde_qs as qs; use serde_qs as qs;
use serde_qs::actix::QsQueryConfig; use serde_qs::actix::QsQueryConfig;

View File

@ -1,5 +1,6 @@
use crate::actors::search::{Find, Search}; use crate::actors::search::{Find, Search};
use crate::types::*; use crate::types::*;
use crate::utils::ParsedIngredient;
use crate::{entities, entities::prelude::*, filters}; use crate::{entities, entities::prelude::*, filters};
use actix_files::Files; use actix_files::Files;
use actix_identity::Identity; use actix_identity::Identity;
@ -300,7 +301,7 @@ struct ImageUpload {
#[template(path = "recipies/create_form.jinja", ext = "html")] #[template(path = "recipies/create_form.jinja", ext = "html")]
struct RecipeForm { struct RecipeForm {
title: String, title: String,
summary: String, summary: Option<String>,
ingredients: String, ingredients: String,
steps: String, steps: String,
tags: String, tags: String,
@ -324,7 +325,7 @@ struct RecipeForm {
struct EditRecipeForm { struct EditRecipeForm {
id: i32, id: i32,
title: String, title: String,
summary: String, summary: Option<String>,
ingredients: String, ingredients: String,
steps: String, steps: String,
tags: String, tags: String,
@ -648,6 +649,7 @@ async fn show(
name: ingredients.get(&ri.ingredient_id)?.to_owned(), name: ingredients.get(&ri.ingredient_id)?.to_owned(),
qty: ri.qty, qty: ri.qty,
unit: ri.unit, unit: ri.unit,
sidenote: ri.sidenote,
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -674,7 +676,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
let ingredients = Ingredients::find().all(&**db).await.unwrap_or_default(); let ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
RecipeForm { RecipeForm {
title: "".into(), title: "".into(),
summary: "".into(), summary: None,
ingredients: "".into(), ingredients: "".into(),
steps: "".into(), steps: "".into(),
tags: "".into(), tags: "".into(),
@ -741,7 +743,8 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
use sea_orm::ActiveValue::*; use sea_orm::ActiveValue::*;
let mut summary = String::new(); let mut summary = String::new();
let parser = pulldown_cmark::Parser::new(&form.summary); let raw_summary = form.summary.unwrap_or_default();
let parser = pulldown_cmark::Parser::new(&raw_summary);
pulldown_cmark::html::push_html(&mut summary, parser); pulldown_cmark::html::push_html(&mut summary, parser);
let time = match form.time { let time = match form.time {
@ -793,7 +796,7 @@ async fn create_ingredients(
entities::ingredients::Column::Name.is_in( entities::ingredients::Column::Name.is_in(
ingredients ingredients
.iter() .iter()
.map(|(name, ..)| name.to_string()) .map(|ParsedIngredient { name, .. }| name.to_string())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
), ),
) )
@ -808,11 +811,13 @@ async fn create_ingredients(
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let missing = ingredients let missing = ingredients
.iter() .iter()
.filter(|(name, ..)| !known.contains(name)) .filter(|ParsedIngredient { name, .. }| !known.contains(name))
.map(|(name, ..)| entities::ingredients::ActiveModel { .map(
|ParsedIngredient { name, .. }| entities::ingredients::ActiveModel {
name: Set(name.to_owned()), name: Set(name.to_owned()),
..Default::default() ..Default::default()
}) },
)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut v = Vec::with_capacity(missing.len()); let mut v = Vec::with_capacity(missing.len());
for missing in missing { for missing in missing {
@ -835,15 +840,23 @@ async fn create_ingredients(
RecipeIngredients::insert_many( RecipeIngredients::insert_many(
ingredients ingredients
.into_iter() .into_iter()
.filter_map(|(name, unit, qty)| { .filter_map(
|ParsedIngredient {
name,
unit,
qty,
sidenote,
}| {
Some(entities::recipe_ingredients::ActiveModel { Some(entities::recipe_ingredients::ActiveModel {
ingredient_id: Set(*map.get(&name)?), ingredient_id: Set(*map.get(&name)?),
qty: Set(qty), qty: Set(qty),
unit: Set(unit.unwrap_or_default()), unit: Set(unit.unwrap_or_default()),
recipe_id: Set(recipe_id), recipe_id: Set(recipe_id),
sidenote: Set(sidenote),
..Default::default() ..Default::default()
}) })
}) },
)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.exec(&mut *t) .exec(&mut *t)
@ -1082,7 +1095,7 @@ async fn edit_recipe(
Ok(EditRecipeForm { Ok(EditRecipeForm {
id, id,
title: recipe.title, title: recipe.title,
summary: recipe.summary.unwrap_or_default(), summary: recipe.summary,
ingredients: recipe_ingredients ingredients: recipe_ingredients
.into_iter() .into_iter()
.filter_map(|ingredient| { .filter_map(|ingredient| {
@ -1168,7 +1181,8 @@ async fn save_recipe_changes(
use sea_orm::ActiveValue::*; use sea_orm::ActiveValue::*;
let mut summary = String::new(); let mut summary = String::new();
let parser = pulldown_cmark::Parser::new(&form.summary); let raw_summary = form.summary.unwrap_or_default();
let parser = pulldown_cmark::Parser::new(&raw_summary);
pulldown_cmark::html::push_html(&mut summary, parser); pulldown_cmark::html::push_html(&mut summary, parser);
let time = match form.time { let time = match form.time {

View File

@ -11,6 +11,7 @@ pub struct Ingredient {
pub qty: String, pub qty: String,
pub name: String, pub name: String,
pub unit: String, pub unit: String,
pub sidenote: Option<String>,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]

View File

@ -32,12 +32,32 @@ pub fn parse_steps(s: &str) -> Result<Vec<(String, Option<String>)>, Error> {
}) })
} }
pub fn parse_ingredients(s: &str) -> Result<Vec<(String, Option<String>, String)>, Error> { #[derive(Debug, PartialEq)]
pub struct ParsedIngredient {
pub qty: String,
pub name: String,
pub unit: Option<String>,
pub sidenote: Option<String>,
}
pub fn parse_ingredients(s: &str) -> Result<Vec<ParsedIngredient>, Error> {
s.lines() s.lines()
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| { .map(|s| {
let mut pieces = s let sidenote = s
.chars()
.skip_while(|c| *c != '(')
.skip_while(|c| *c == '(')
.take_while(|c| *c != ')')
.collect::<String>()
.trim()
.to_string();
let d = s.chars().take_while(|c| *c != '(');
let pieces = d.collect::<String>();
let mut pieces = pieces
.trim()
.split_whitespace() .split_whitespace()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -46,12 +66,22 @@ pub fn parse_ingredients(s: &str) -> Result<Vec<(String, Option<String>, String)
// no unit // no unit
2 => { 2 => {
let qty = pieces.remove(0); let qty = pieces.remove(0);
Ok((pieces.join(" "), None, qty.to_string())) Ok(ParsedIngredient {
name: pieces.join(" "),
unit: None,
qty: qty.to_string(),
sidenote: Some(sidenote).filter(|s| !s.is_empty()),
})
} }
_ => { _ => {
let qty = pieces.remove(0); let qty = pieces.remove(0);
let unit = pieces.remove(0); let unit = pieces.remove(0);
Ok((pieces.join(" "), Some(unit.to_string()), qty.to_string())) Ok(ParsedIngredient {
name: pieces.join(" "),
unit: Some(unit.to_string()),
qty: qty.to_string(),
sidenote: Some(sidenote).filter(|s| !s.is_empty()),
})
} }
} }
}) })
@ -75,3 +105,27 @@ pub async fn refresh_records(db: Data<DatabaseConnection>, search: Data<Search>)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let _ = search.send(Refresh { records }).await; let _ = search.send(Refresh { records }).await;
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ingredient_without_unit() {}
#[test]
fn ingredient_with_unit() {}
#[test]
fn ingredient_with_summary() {
let s = "1 szt pomarańcza (może być czerwona)";
let ingredient = parse_ingredients(s).unwrap_or_default();
let expected = vec![ParsedIngredient {
qty: "1".into(),
unit: Some("szt".into()),
name: "pomarańcza".into(),
sidenote: Some("może być czerwona".into()),
}];
assert_eq!(ingredient, expected);
}
}

View File

@ -27,7 +27,7 @@
<label for="summary" class="flex gap-4"> <label for="summary" class="flex gap-4">
<span class="w-24 shrink-0">Summary:</span> <span class="w-24 shrink-0">Summary:</span>
<div class="flex gap-2 w-full"> <div class="flex gap-2 w-full">
<textarea id="summary" name="summary" class="textarea-with-view" required>{{summary}}</textarea> <textarea id="summary" name="summary" class="textarea-with-view" required>{{summary|some_or_default}}</textarea>
<div class="content w-1/2"></div> <div class="content w-1/2"></div>
</div> </div>
</label> </label>

View File

@ -44,6 +44,13 @@
<span class="font-bold"> <span class="font-bold">
{{ ingredient.name }} {{ ingredient.name }}
</span> </span>
{% match ingredient.sidenote %}
{% when Some with (sidenote) %}
<span class="mr-1">
&nbsp;({{ sidenote }})
</span>
{% when None %}
{% endmatch %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -34,7 +34,7 @@
<label for="summary" class="flex gap-4"> <label for="summary" class="flex gap-4">
<span class="w-24 shrink-0">Summary:</span> <span class="w-24 shrink-0">Summary:</span>
<div class="flex gap-2 w-full"> <div class="flex gap-2 w-full">
<textarea id="summary" name="summary" class="textarea-with-view" required>{{summary}}</textarea> <textarea id="summary" name="summary" class="textarea-with-view" required>{{summary|some_or_default}}</textarea>
<div class="content w-1/2"></div> <div class="content w-1/2"></div>
</div> </div>
</label> </label>

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub mod prelude; pub mod prelude;

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::ingredients::Entity as Ingredients; pub use super::ingredients::Entity as Ingredients;
pub use super::recipe_ingredients::Entity as RecipeIngredients; pub use super::recipe_ingredients::Entity as RecipeIngredients;

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,6 +12,7 @@ pub struct Model {
pub qty: String, pub qty: String,
pub unit: String, pub unit: String,
pub recipe_id: i32, pub recipe_id: i32,
pub sidenote: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -1,4 +1,4 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.11 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table; mod m20220101_000001_create_table;
mod m20220101_000002_change_quantity_type; mod m20220101_000002_change_quantity_type;
mod m20220101_000003_add_sidenote_to_recipe_ingredient;
pub struct Migrator; pub struct Migrator;
@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
vec![ vec![
Box::new(m20220101_000001_create_table::Migration), Box::new(m20220101_000001_create_table::Migration),
Box::new(m20220101_000002_change_quantity_type::Migration), Box::new(m20220101_000002_change_quantity_type::Migration),
Box::new(m20220101_000003_add_sidenote_to_recipe_ingredient::Migration),
] ]
} }
} }

View File

@ -33,9 +33,4 @@ impl MigrationTrait for Migration {
#[derive(DeriveIden)] #[derive(DeriveIden)]
enum RecipeIngredient { enum RecipeIngredient {
RecipeIngredients, RecipeIngredients,
Id,
IngredientId,
Qty,
Unit,
RecipeId,
} }