1use std::{
2 collections::HashSet,
3 ffi::OsString,
4 fs,
5 path::{Path, PathBuf},
6};
7
8use serde::Serialize;
9use tera::Context;
10
11use crate::{
12 error::Result,
13 recipe::directory::Directory,
14 template,
15 util::{append_os_file_ext, copy_dir, UniqueNameFinder},
16};
17
18pub trait TextFilter {
19 fn filter<S: AsRef<str>>(&self, text: S) -> String;
20}
21
22const INDEX_NAME: &str = "index";
23
24const IMAGE_DIR: &str = "images";
25const RECIPE_DIR: &str = "recipes";
26const STATIC_DIR: &str = "static";
27
28#[derive(Debug, Serialize)]
29struct IndexEntry {
30 pub title: String,
31 pub path: PathBuf,
32 pub tags: Vec<String>,
33 pub image_path: Option<PathBuf>,
34}
35
36pub struct Generator<F> {
37 engine: template::Engine,
38 image_file_exts: Vec<OsString>,
39 output_file_ext: OsString,
40 file_name_filter: F,
41}
42
43impl<F> Generator<F> {
44 pub fn new(
45 engine: template::Engine,
46 image_file_exts: Vec<OsString>,
47 output_file_ext: OsString,
48 file_name_filter: F,
49 ) -> Self {
50 Self {
51 engine,
52 image_file_exts,
53 output_file_ext,
54 file_name_filter,
55 }
56 }
57}
58
59impl<F: TextFilter> Generator<F> {
60 pub fn generate(&self, recipe_dir: &Path, output_dir: &Path) -> Result<()> {
61 let index = self.render_recipes(recipe_dir, output_dir)?;
62 if self.engine.has_index_template() {
63 self.render_index(&index, output_dir)?;
64 }
65 let static_path = self.engine.static_path();
66 if static_path.exists() {
67 copy_dir(static_path, output_dir.join(STATIC_DIR))?;
68 }
69 Ok(())
70 }
71
72 fn render_index(&self, entries: &[IndexEntry], output_dir: &Path) -> Result<()> {
73 let tags = get_distinct_tags(entries);
74 let mut entries: Vec<_> = entries.iter().collect();
75 entries.sort_by_key(|entry| &entry.title);
76 let mut context = Context::new();
77 context.insert("recipes", &entries);
78 context.insert("tags", &tags);
79 let path = output_dir.join(append_os_file_ext(INDEX_NAME, &self.output_file_ext));
80 fs::create_dir_all(path.parent().expect("invalid index template path"))?;
81 let file = fs::File::create(path)?;
82 self.engine.render_index(context, file)
83 }
84
85 fn copy_image(
86 &self,
87 directory: &Directory,
88 slug: &str,
89 output_dir: &Path,
90 ) -> Result<Option<OsString>> {
91 directory
92 .image_file_name(&self.image_file_exts)?
93 .map(|name| {
94 let ext = Path::new(&name).extension().unwrap();
95 let new_name = append_os_file_ext(slug, ext);
96 fs::copy(directory.path().join(&name), output_dir.join(&new_name))?;
97 Ok(new_name)
98 })
99 .transpose()
100 }
101
102 fn render_recipes(&self, recipe_dir: &Path, output_dir: &Path) -> Result<Vec<IndexEntry>> {
103 let recipe_output_dir = output_dir.join(Path::new(RECIPE_DIR));
104 fs::create_dir_all(&recipe_output_dir)?;
105 let image_output_dir = output_dir.join(Path::new(IMAGE_DIR));
106 fs::create_dir_all(&image_output_dir)?;
107 let mut name_finder = UniqueNameFinder::new(" (", ")");
108 let mut index_entries = Vec::new();
109 for directory in Directory::list_all(recipe_dir)? {
110 let recipe = directory.load()?;
111 let name = name_finder.find(self.file_name_filter.filter(&recipe.title));
112 let recipe_file_name = append_os_file_ext(&name, &self.output_file_ext);
113 let relative_recipe_path = Path::new(RECIPE_DIR).join(&recipe_file_name);
114 let image_file_name = self.copy_image(&directory, &name, &image_output_dir)?;
115 let relative_image_path = image_file_name.map(|name| Path::new(IMAGE_DIR).join(name));
116 let mut context = Context::new();
117 context.insert("recipe", &recipe);
118 context.insert("path", relative_recipe_path.to_str().unwrap());
119 context.insert(
120 "image_path",
121 &relative_image_path
122 .as_deref()
123 .map(|path| path.to_str().unwrap()),
124 );
125 let mut tags = recipe.metadata.tags;
126 tags.sort();
127 let index_entry = IndexEntry {
128 title: recipe.title,
129 path: relative_recipe_path,
130 tags,
131 image_path: relative_image_path,
132 };
133 let file = fs::File::create(recipe_output_dir.join(recipe_file_name))?;
134 self.engine.render_recipe(context, file)?;
135 index_entries.push(index_entry);
136 }
137 Ok(index_entries)
138 }
139}
140
141fn get_distinct_tags(entries: &[IndexEntry]) -> Vec<&String> {
142 let mut tags = HashSet::new();
143 for entry in entries {
144 for tag in &entry.tags {
145 if tags.contains(tag) {
146 } else {
147 tags.insert(tag);
148 }
149 }
150 }
151 let mut tags: Vec<_> = tags.into_iter().collect();
152 tags.sort();
153 tags
154}
155
156#[cfg(test)]
157mod tests {
158 use std::{
159 collections::HashMap,
160 fs::File,
161 io::{Cursor, Write},
162 };
163
164 use tempfile::tempdir;
165
166 use crate::recipe::Recipe;
167
168 use super::*;
169
170 struct FileNameFilter;
171
172 impl TextFilter for FileNameFilter {
173 fn filter<S: AsRef<str>>(&self, text: S) -> String {
174 text.as_ref().to_uppercase()
175 }
176 }
177
178 #[test]
179 fn test() -> Result<()> {
180 let temp_dir = tempdir()?;
182 let temp_path = temp_dir.path();
183 let tpl_dir = temp_path.join("template");
184 let static_dir = tpl_dir.join(STATIC_DIR);
185 let tera_dir = tpl_dir.join("tera");
186 let recipe_dir = temp_path.join(RECIPE_DIR);
187 let output_dir = temp_path.join("output");
188 fs::create_dir_all(&tpl_dir)?;
189 fs::create_dir_all(&static_dir)?;
190 fs::create_dir_all(&tera_dir)?;
191 fs::create_dir_all(&recipe_dir)?;
192 fs::create_dir_all(&output_dir)?;
193
194 let mut file = File::create(tera_dir.join("recipe.html"))?;
196 writeln!(file, "title: {{{{ recipe.title }}}}")?;
197
198 let mut file = File::create(tera_dir.join("index.html"))?;
200 writeln!(
201 file,
202 "{{% for r in recipes -%}}{{{{ r.title }}}}{{%- endfor %}}"
203 )?;
204
205 let recipe_str = "title\n\nYield: 1\n\nIngredients\n- nothing\n\nInstructions\n- none";
207
208 let mut recipe1 = Recipe::parse_from(Cursor::new(recipe_str))?;
210 recipe1.title = "recipe 1".into();
211 fs::create_dir(recipe_dir.join("recipe 1"))?;
212 let mut file = File::create(recipe_dir.join("recipe 1/recipe 1.recipe"))?;
213 writeln!(file, "{}", recipe1)?;
214
215 let mut recipe1_2 = Recipe::parse_from(Cursor::new(recipe_str))?;
217 recipe1_2.title = "recipe 1".into();
218 fs::create_dir(recipe_dir.join("recipe 1 (2)"))?;
219 let mut file = File::create(recipe_dir.join("recipe 1 (2)/recipe 1 (2).recipe"))?;
220 writeln!(file, "{}", recipe1_2)?;
221
222 let mut recipe2 = Recipe::parse_from(Cursor::new(recipe_str))?;
224 recipe2.title = "recipe 2".into();
225 fs::create_dir(recipe_dir.join("recipe 2"))?;
226 let mut file = File::create(recipe_dir.join("recipe 2/recipe 2.recipe"))?;
227 writeln!(file, "{}", recipe2)?;
228
229 File::create(recipe_dir.join("recipe 2/recipe 2.jpg"))?;
231
232 File::create(static_dir.join("test.txt"))?;
234
235 let mut context = Context::new();
237 context.insert(
238 "app",
239 &HashMap::from([("name", "name"), ("homepage", "homepage")]),
240 );
241 let mut engine = template::Engine::new(&tpl_dir, true, "html", None)?;
242 engine.forced_context = Some(context);
243 let image_file_exts = vec!["jpg".into()];
244
245 let generator = Generator::new(engine, image_file_exts, "html".into(), FileNameFilter);
246 generator.generate(&recipe_dir, &output_dir)?;
247
248 let recipe_output_dir = output_dir.join(RECIPE_DIR);
250 let image_output_dir = output_dir.join(IMAGE_DIR);
251 let static_output_dir = output_dir.join(STATIC_DIR);
252 let recipe1 = fs::read_to_string(recipe_output_dir.join("RECIPE 1.html"))?;
253 assert_eq!(recipe1, "title: recipe 1\n");
254 let recipe1_2 = fs::read_to_string(recipe_output_dir.join("RECIPE 1 (2).html"))?;
255 assert_eq!(recipe1_2, "title: recipe 1\n");
256 let recipe2 = fs::read_to_string(recipe_output_dir.join("RECIPE 2.html"))?;
257 assert_eq!(recipe2, "title: recipe 2\n");
258 assert!(image_output_dir.join("RECIPE 2.jpg").exists());
259 let index = fs::read_to_string(output_dir.join("index.html"))?;
260 assert_eq!(index, concat!("recipe 1", "recipe 1", "recipe 2", "\n"));
261 assert!(static_output_dir.join("test.txt").exists());
262
263 Ok(())
264 }
265}