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::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 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, rel_path: PathBuf, date: chrono::NaiveDate, note: String, rss_note: String, images: Vec, rendered: BreadRendered, } //impl From for failure::Error { // fn from(x: std::option::NoneError) -> Self { // failure::err_msg("Expected something, found nothing.") // } //} #[derive(Debug)] struct BreadLink { label: String, url: 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); 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 = 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_real_path = config.web_path.join(img_path); // Create the thumb { let mut img_file = fs::File::open(&image_real_path)?; let mut hasher = Blake2b::new(); io::copy(&mut img_file, &mut hasher)?; let hash = base64::encode(&hasher.result()); let hash_key = thumb_path.to_str().unwrap(); 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)?; let im = im.thumbnail(500, 500); im.save(&thumb_path)?; config.image_hashes.put(hash_key.to_string(), hash); } } // Prepare the thumb card for the gallery page { 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); } // Add to RSS { let image_url: String = config.base_url.to_owned() + "/" + &image_path_encoded; 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(&self.date).unwrap(); let dt = date_formatted.and_hms(12, 0, 0); let mut descr = String::new(); 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, self.images.len() )); self.rendered.url = link.clone(); let item: rss::Item = ItemBuilder::default() .title(date.clone()) .link(link) .description(descr) .guid(guid) .pub_date(dt.to_rfc2822()) .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 = config .template("detail.html")? .replace("{head}", &head_tpl.replace("{title}", &win_title)) .replace("{title}", &win_title) .replace("{date}", &date_slug) .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 &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(config.web_path.join(detail_file)).unwrap(); f.write(detail.as_bytes()).unwrap(); self.rendered.detail = detail; } Ok(()) } 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(), } } 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 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(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(()) }