bread gallery data and generator script
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
bread-gallery/src/main.rs

403 lines
13 KiB

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<rss::Item>,
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<PathBuf>,
rendered: BreadRendered,
}
//impl From<std::option::NoneError> 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<BreadLink>, next : Option<BreadLink>) -> 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() {
"<i>There's no note about this bread.</i>"
} 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<Utc> = 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("<hr>");
}
descr.push_str(note);
descr.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"><p><i>Open the link for full-res photos ({} total)</i>",
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!("<a class=\"prev\" href=\"{}\" title=\"{}\">&lt;</a>", b.url, b.label),
None => "".to_string()
}))
.replace("{next}", &(match next {
Some(b) => format!("<a class=\"next\" href=\"{}\" title=\"{}\">&gt;</a>", 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!(
" <a href=\"{src}\"><img src=\"{src}\"></a>\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<Bread, std::io::Error> {
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(&note);
}
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<DirEntry> = 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<String, String>,
image_hashes: hash_dict::HashDict,
}
impl GalleryConfig {
fn template(&mut self, name: &str) -> Fallible<String> {
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<DirEntry> = 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> = 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(())
}