diff --git a/Cargo.lock b/Cargo.lock index 1627521..91133e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "blake2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", + "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", "image 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "image-utils 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "markdown 0.2.0 (git+https://github.com/johannhof/markdown.rs)", diff --git a/Cargo.toml b/Cargo.toml index f120b54..104597a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,5 @@ image-utils = "0.2.0" image = "0.21.0" blake2 = "0.8.0" base64 = "0.10.1" +failure = "0.1.5" + diff --git a/src/hash_dict.rs b/src/hash_dict.rs index 7c727c7..211aab6 100644 --- a/src/hash_dict.rs +++ b/src/hash_dict.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use std::fs::File; +use std::io; use std::io::Read; use std::io::Write; -use std::io; use std::path::PathBuf; // file-stored hash map used to prevent needless image regenerating @@ -14,7 +14,7 @@ pub struct HashDict { } impl HashDict { - pub fn load(path: PathBuf) -> Result { + pub fn load(path: PathBuf) -> Result { let mut hd = HashDict { hashes: HashMap::new(), path, @@ -33,18 +33,19 @@ impl HashDict { for l in lines { let halves: Vec<&str> = l.split("\t").collect(); if halves.len() == 2 { - hd.hashes.insert(halves[0].to_string(), halves[1].to_string()); + hd.hashes + .insert(halves[0].to_string(), halves[1].to_string()); } } Ok(hd) } - pub fn get(&self, key : &str) -> Option<&String> { + pub fn get(&self, key: &str) -> Option<&String> { self.hashes.get(key) } - pub fn put(&mut self, key : String, value: String) { + pub fn put(&mut self, key: String, value: String) { self.hashes.insert(key, value); self.any_change = true; } diff --git a/src/main.rs b/src/main.rs index a77ebcf..a06285d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,38 @@ +use blake2::{Blake2b, Digest}; +use chrono; +use chrono::offset::TimeZone; +use chrono::Date; +use chrono::NaiveDate; +use chrono::Utc; +use failure::Fallible; +use image_utils; +use markdown; +use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; +use rss::{Channel, ChannelBuilder, Guid, ItemBuilder}; use std::env; -use std::io; use std::fs; use std::fs::DirEntry; use std::fs::File; +use std::fs::OpenOptions; +use std::io; use std::io::prelude::*; use std::path::{Path, PathBuf}; -use chrono; -use chrono::NaiveDate; -use markdown; -use std::fs::OpenOptions; -use rss::{Channel, ChannelBuilder, Item, ItemBuilder, Guid}; -use percent_encoding::{utf8_percent_encode, DEFAULT_ENCODE_SET}; -use image_utils; -use chrono::offset::TimeZone; -use chrono::Date; -use chrono::Utc; -use blake2::{Digest,Blake2b}; use base64; +use std::collections::HashMap; mod hash_dict; +#[derive(Debug)] +struct BreadRendered { + thumb: String, + detail: String, + rss_item: Option, + title: String, + url: String, + detail_fname: String, +} + #[derive(Debug)] struct Bread { path: PathBuf, @@ -29,243 +41,363 @@ struct Bread { note: String, rss_note: String, images: Vec, + rendered: BreadRendered, } -impl Bread { - fn thumb_photo(&self) -> (&str, &str) { - let mut first_img : &PathBuf = self.images.get(0).unwrap(); - - for im in &self.images { - if im.file_name().unwrap().to_str().unwrap().contains("cover") { - first_img = im; - break; - } - } - - let img_path = first_img.to_str().unwrap(); - let img_alt = first_img.file_name().unwrap().to_str().unwrap(); - - (img_path, img_alt) - } - - fn parse(base_dir : &PathBuf, bread_dir : &DirEntry) -> Result { - let bpath = bread_dir.path(); - let mut note = String::new(); - let mut rss_note = String::new(); +//impl From for failure::Error { +// fn from(x: std::option::NoneError) -> Self { +// failure::err_msg("Expected something, found nothing.") +// } +//} - let mut note_path = bpath.join("note.txt"); - let mut rss_note_path = bpath.join("rss.txt"); - - // try a md one as a fallback - if !note_path.exists() { - note_path = bpath.join("note.md"); - } - - if !rss_note_path.exists() { - rss_note_path = bpath.join("rss.md"); - } - - if note_path.exists() { - let mut note_file = File::open(note_path)?; - note_file.read_to_string(&mut note)?; - note = markdown::to_html(¬e); - } - - if rss_note_path.exists() { - let mut note_file = File::open(rss_note_path)?; - note_file.read_to_string(&mut rss_note)?; - rss_note = markdown::to_html(&rss_note); - } - - let mut bread_files: Vec = fs::read_dir(&bpath)?.map(|e| e.unwrap()).collect(); - bread_files.sort_by(|x, y| x.file_name().cmp(&y.file_name())); - - let images = bread_files.iter().filter(|&f| { - let fname = f.file_name(); - let name = fname.to_str().unwrap(); - return - name.ends_with(".png") || - name.ends_with(".jpg") || - name.ends_with(".jpeg") || - name.ends_with(".gif"); - }).map(|x| x.path().strip_prefix(base_dir).unwrap().to_path_buf()).collect(); - - return Ok(Bread { - date: NaiveDate::parse_from_str(bpath.file_name().unwrap().to_str().unwrap(), "%Y-%m-%d").unwrap(), - rel_path: bpath.strip_prefix(base_dir).unwrap().to_path_buf(), - path: bpath, - note, - rss_note, - images - }); - } +#[derive(Debug)] +struct BreadLink { + label: String, + url: String, } -fn main() { - let cwd = env::current_dir().unwrap(); - let web_path = Path::new(&cwd).join("web"); - let data_path = web_path.join("data"); - let tpl_path = web_path.join("templates"); - let thumbs_path = web_path.join("thumbs"); - - let mut bread_dirs: Vec = fs::read_dir(&data_path).unwrap().map(|e| e.unwrap()).collect(); - bread_dirs.sort_by(|x, y| x.file_name().cmp(&y.file_name())); - - let mut breads : Vec = Vec::new(); - - for bread_dir in bread_dirs { - if let Ok(b) = Bread::parse(&web_path, &bread_dir) { - breads.push(b); - } - } - - let mut main_tpl = String::new(); - let mut thumb_tpl = String::new(); - let mut detail_tpl = String::new(); - let mut head_tpl = String::new(); - File::open(tpl_path.join("index.html")).unwrap().read_to_string(&mut main_tpl).unwrap(); - File::open(tpl_path.join("_thumb.html")).unwrap().read_to_string(&mut thumb_tpl).unwrap(); - File::open(tpl_path.join("_head.html")).unwrap().read_to_string(&mut head_tpl).unwrap(); - File::open(tpl_path.join("detail.html")).unwrap().read_to_string(&mut detail_tpl).unwrap(); - - let mut thumbs = Vec::::new(); - - let mut channel : Channel = ChannelBuilder::default() - .title("Piggo's Bread Gallery") - .link("https://www.ondrovo.com/bread") - .description("Sourdough feed") - .build() - .unwrap(); - - let mut channel_items = Vec::::new(); - - let mut hashes = hash_dict::HashDict::load(cwd.join(".hashes.txt")).unwrap(); - - // TODO separate thumbs by year and generate per-year pages - // TODO limit RSS to last N breads - - for bread in &breads { - let date = bread.date.format("%Y/%m/%d").to_string(); - let date_slug = bread.date.format("%Y-%m-%d").to_string(); +impl Bread { + fn compile(&mut self, config: &mut GalleryConfig, prev : Option, next : Option) -> Fallible<()> { + let date = self.date.format("%Y/%m/%d").to_string(); + let date_slug = self.date.format("%Y-%m-%d").to_string(); let detail_file = date_slug.clone() + ".html"; - println!("+ {}", date_slug); - let (img_path, img_alt) = bread.thumb_photo(); - let note = if bread.note.is_empty() { "There's no note about this bread." } else { &bread.note }; - let rss_note = &bread.rss_note; + self.rendered.title = date.clone(); + self.rendered.detail_fname = detail_file.clone(); + + // figure out the thumbnail pic + let (img_path, img_alt) = { + let mut first_img: &PathBuf = self.images.get(0).expect(&format!("No images for bread {}", date_slug)); + for im in &self.images { + if im.file_name().unwrap().to_str().unwrap().contains("cover") { + first_img = im; + break; + } + } + ( + first_img.to_str().unwrap().to_owned(), + first_img.file_name().unwrap().to_str().unwrap().to_owned(), + ) + }; + + let note = if self.note.is_empty() { + "There's no note about this bread." + } else { + &self.note + }; let thumb_fname = date_slug.clone() + "." + Path::new(&img_path).extension().unwrap().to_str().unwrap(); - let thumb_path = thumbs_path.join(&thumb_fname); - let thumb_relpath = thumb_path.strip_prefix(&web_path).unwrap(); + let thumb_path = config.thumbs_path.join(&thumb_fname); + let thumb_relpath = thumb_path.strip_prefix(&config.web_path)?; - let image_path_encoded = utf8_percent_encode(thumb_relpath.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string(); + let image_path_encoded = + utf8_percent_encode(thumb_relpath.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string(); - // TODO keep the original path in bread so we dont have to reconstruct it here - let image_real_path = web_path.join(img_path); + let image_real_path = config.web_path.join(img_path); // Create the thumb { - let mut img_file = fs::File::open(&image_real_path).unwrap(); + let mut img_file = fs::File::open(&image_real_path)?; let mut hasher = Blake2b::new(); - io::copy(&mut img_file, &mut hasher).unwrap(); + io::copy(&mut img_file, &mut hasher)?; let hash = base64::encode(&hasher.result()); let hash_key = thumb_path.to_str().unwrap(); - let old_hash = hashes.get(hash_key); + let old_hash = config.image_hashes.get(hash_key); if old_hash.is_none() || !old_hash.unwrap().eq(&hash) { println!("building thumb..."); - let im = image::open(&image_real_path).unwrap(); + let im = image::open(&image_real_path)?; let im = im.thumbnail(500, 500); - im.save(&thumb_path).unwrap(); + im.save(&thumb_path)?; - hashes.put(hash_key.to_string(), hash); + config.image_hashes.put(hash_key.to_string(), hash); } } // Prepare the thumb card for the gallery page { - let thumb = thumb_tpl + self.rendered.thumb = config + .template("_thumb.html")? .replace("{detail_url}", &detail_file) .replace("{img_src}", &image_path_encoded) .replace("{img_alt}", &img_alt) .replace("{title}", &date); - - thumbs.push(thumb); } // Add to RSS { - let image_url : String = channel.link().to_string() + "/" + &image_path_encoded; + let image_url: String = config.base_url.to_owned() + "/" + &image_path_encoded; - let link : String = channel.link().to_string() + "/" + &detail_file; + let link: String = config.base_url.to_owned() + "/" + &detail_file; let mut guid = Guid::default(); guid.set_value(link.clone()); guid.set_permalink(true); - let date_formatted : Date = chrono::Utc.from_local_date(&bread.date).unwrap(); - let dt = date_formatted.and_hms(12,0,0); - + let date_formatted: Date = chrono::Utc.from_local_date(&self.date).unwrap(); + let dt = date_formatted.and_hms(12, 0, 0); + let mut descr = String::new(); - if !rss_note.is_empty() { - descr.push_str(&(rss_note.to_string() + "
")); + if !self.rss_note.is_empty() { + descr.push_str(&self.rss_note); + descr.push_str("
"); } descr.push_str(note); - descr.push_str(&format!("\"{}\"

Open the link for full-res photos ({} total)", image_url, img_alt, bread.images.len())); + descr.push_str(&format!( + "\"{}\"

Open the link for full-res photos ({} total)", + image_url, + img_alt, + self.images.len() + )); - channel_items.push(ItemBuilder::default() + self.rendered.url = link.clone(); + + let item: rss::Item = ItemBuilder::default() .title(date.clone()) - .link(link.clone()) + .link(link) .description(descr) .guid(guid) .pub_date(dt.to_rfc2822()) - .build().unwrap()); + .build().unwrap(); + + self.rendered.rss_item = Some(item); } + let head_tpl = config.template("_head.html")?; + // Generate the detail page { let win_title = format!("Bread from {}", date); - - let detail = detail_tpl + + let detail = config + .template("detail.html")? .replace("{head}", &head_tpl.replace("{title}", &win_title)) .replace("{title}", &win_title) .replace("{date}", &date_slug) - .replace("{url}", &format!("https://www.ondrovo.com/bread/{}", detail_file)) - .replace("{thumb_url}", &format!("https://www.ondrovo.com/bread/thumbs/{}", thumb_fname)) + .replace("{url}", &format!("{}/{}", config.base_url, detail_file)) + .replace( + "{thumb_url}", + &format!("{}/thumbs/{}", config.base_url, thumb_fname), + ) .replace("{heading}", &date) + .replace("{prev}", &(match prev { + Some(b) => format!("<", b.url, b.label), + None => "".to_string() + })) + .replace("{next}", &(match next { + Some(b) => format!(">", b.url, b.label), + None => "".to_string() + })) .replace("{note}", note.trim()); let mut pics = String::new(); - for img in &bread.images { - pics.push_str(&format!(" \n", src=&utf8_percent_encode(img.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string())) + for img in &self.images { + pics.push_str(&format!( + " \n", + src = &utf8_percent_encode(img.to_str().unwrap(), DEFAULT_ENCODE_SET).to_string() + )) } let detail = detail.replace("{images}", &pics.trim()); - let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join(detail_file)).unwrap(); + let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join(detail_file)).unwrap(); f.write(detail.as_bytes()).unwrap(); + + self.rendered.detail = detail; } + + Ok(()) } - hashes.save(); + fn to_link(&self) -> BreadLink { + BreadLink { + label: self.date.format("%Y/%m/%d").to_string(), + url: self.date.format("%Y-%m-%d.html").to_string(), + } + } - { - // make thumbs go from the newest to the oldest - thumbs.reverse(); - - println!("Building the gallery page"); - let main = main_tpl.replace("{breads}", &thumbs.join("").trim()) - .replace("{head}", &head_tpl.replace("{title}", "Piggo's breads").trim()); + fn parse(base_dir: &PathBuf, bread_dir: &DirEntry) -> Result { + let bpath = bread_dir.path(); + let mut note = String::new(); + let mut rss_note = String::new(); - let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join("index.html")).unwrap(); - f.write(main.as_bytes()).unwrap(); + let mut note_path = bpath.join("note.txt"); + let mut rss_note_path = bpath.join("rss.txt"); + + // try a md one as a fallback + if !note_path.exists() { + note_path = bpath.join("note.md"); + } + + if !rss_note_path.exists() { + rss_note_path = bpath.join("rss.md"); + } + + if note_path.exists() { + let mut note_file = File::open(note_path)?; + note_file.read_to_string(&mut note)?; + note = markdown::to_html(¬e); + } + + if rss_note_path.exists() { + let mut note_file = File::open(rss_note_path)?; + note_file.read_to_string(&mut rss_note)?; + rss_note = markdown::to_html(&rss_note); + } + + let mut bread_files: Vec = fs::read_dir(&bpath)?.map(|e| e.unwrap()).collect(); + bread_files.sort_by(|x, y| x.file_name().cmp(&y.file_name())); + + let images = bread_files + .iter() + .filter(|&f| { + let fname = f.file_name(); + let name = fname.to_str().unwrap(); + return name.ends_with(".png") + || name.ends_with(".jpg") + || name.ends_with(".jpeg") + || name.ends_with(".gif"); + }) + .map(|x| x.path().strip_prefix(base_dir).unwrap().to_path_buf()) + .collect(); + + return Ok(Bread { + date: NaiveDate::parse_from_str( + bpath.file_name().unwrap().to_str().unwrap(), + "%Y-%m-%d", + ) + .unwrap(), + rel_path: bpath.strip_prefix(base_dir).unwrap().to_path_buf(), + path: bpath, + note, + rss_note, + images, + rendered: BreadRendered { + thumb: "".to_string(), + detail: "".to_string(), + rss_item: None, + title: "".to_string(), + url: "".to_string(), + detail_fname: "".to_string() + }, + }); + } +} + +struct GalleryConfig { + web_path: PathBuf, + data_path: PathBuf, + tpl_path: PathBuf, + thumbs_path: PathBuf, + base_url: String, + templates: HashMap, + image_hashes: hash_dict::HashDict, +} + +impl GalleryConfig { + fn template(&mut self, name: &str) -> Fallible { + if let Some(text) = self.templates.get(name) { + return Ok(text.clone()); + } + + let mut tpl = String::new(); + File::open(self.tpl_path.join(name))?.read_to_string(&mut tpl)?; + self.templates.insert(name.to_string(), tpl.clone()); + Ok(tpl) + } +} + +fn main() -> Fallible<()> { + + let mut config = { + let cwd = env::current_dir().unwrap(); + let web_path = cwd.join("web"); + let data_path = web_path.join("data"); + let tpl_path = web_path.join("templates"); + let thumbs_path = web_path.join("thumbs"); + + GalleryConfig { + web_path, + data_path, + tpl_path, + thumbs_path, + base_url: "https://www.ondrovo.com/bread".into(), + templates: HashMap::new(), + image_hashes: hash_dict::HashDict::load(cwd.join(".hashes.txt"))?, + } + }; + + let mut bread_dirs: Vec = fs::read_dir(&config.data_path)?.filter_map(|e| e.ok()).collect(); + bread_dirs.sort_by(|x, y| x.file_name().cmp(&y.file_name())); + + let mut breads: Vec = bread_dirs + .iter() + .filter_map(|p| Bread::parse(&config.web_path, &p).ok()) + .collect(); + + for i in 0..breads.len() { + let preceding = if i <= 0 { None } else { + match breads.get(i - 1) { + Some(b) => Some(b.to_link()), + None => None + } + }; + + let following = match breads.get(i + 1) { + Some(b) => Some(b.to_link()), + None => None + }; + + let cur = breads.get_mut(i).unwrap(); + + cur.compile(&mut config, preceding, following)?; } + let mut channel: Channel = ChannelBuilder::default() + .title("Piggo's Bread Gallery") + .link("https://www.ondrovo.com/bread") + .description("Sourdough feed") + .build() + .unwrap(); + + config.image_hashes.save(); + + let mut start = breads.len() as i32 - 10; + if start < 0 { + start = 0; + } + + // rss { + let mut channel_items = vec![]; + for b in &breads[start as usize..] { + channel_items.push(b.rendered.rss_item.clone().unwrap()); + } + println!("Generating feed..."); - let f = OpenOptions::new().write(true).truncate(true).create(true).open(web_path.join("feed.xml")).unwrap(); + let f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join("feed.xml")).unwrap(); channel.set_items(channel_items); channel.pretty_write_to(f, b' ', 2).unwrap(); } + + // main page + { + // make thumbs go from the newest to the oldest + breads.reverse(); + + let mut thumbs_buf = String::new(); + for b in &breads { + thumbs_buf.push_str(&b.rendered.thumb); + } + + println!("Building the gallery page"); + let main = config.template("index.html")?.replace("{breads}", &thumbs_buf.trim()) + .replace("{head}", config.template("_head.html")?.replace("{title}", "Piggo's breads").trim()); + + let mut f = OpenOptions::new().write(true).truncate(true).create(true).open(config.web_path.join("index.html")).unwrap(); + f.write(main.as_bytes()).unwrap(); + } + + Ok(()) } diff --git a/web/assets/style.css b/web/assets/style.css index 6499358..e804c44 100644 --- a/web/assets/style.css +++ b/web/assets/style.css @@ -93,4 +93,10 @@ a { #crumb a:hover { color: #ccc; -} \ No newline at end of file +} + +.prev, .next { + font-size: 90%; + padding: 0 1em; + +} diff --git a/web/data/2019-02-13/2019-02-13 21.56.35.jpg b/web/data/2019-02-13/2019-02-13 21.56.35.jpg new file mode 100644 index 0000000..4365999 Binary files /dev/null and b/web/data/2019-02-13/2019-02-13 21.56.35.jpg differ diff --git a/web/data/2019-02-13/note.txt b/web/data/2019-02-13/note.txt new file mode 100644 index 0000000..f0418f3 --- /dev/null +++ b/web/data/2019-02-13/note.txt @@ -0,0 +1 @@ +a little flat but A+ taste and cronch diff --git a/web/data/2019-02-18/bread.jpg b/web/data/2019-02-18/bread.jpg new file mode 100644 index 0000000..68c3ab7 Binary files /dev/null and b/web/data/2019-02-18/bread.jpg differ diff --git a/web/data/2019-02-18/note.txt b/web/data/2019-02-18/note.txt new file mode 100644 index 0000000..75e1806 --- /dev/null +++ b/web/data/2019-02-18/note.txt @@ -0,0 +1,3 @@ +The previous bread came out a bit tough. I run out of bread flour then, so it was made from fine flour. May be why. + +This one is almost perfect, but it's still turning tough quickly. I'm investigating ways to keep it fresh longer... If you have a tip, please let me know! diff --git a/web/templates/detail.html b/web/templates/detail.html index 85903c9..08ef64a 100644 --- a/web/templates/detail.html +++ b/web/templates/detail.html @@ -25,7 +25,7 @@ -

{heading}

+

{prev}{heading}{next}