Just what the world needs, another silly Rust re-write! But it was a good exercise in learning. There's a lot of messy things, which is why this is 0.2.0-pre.1. Going to make some cleaning passes after landing this.
This commit is contained in:
parent
94a5e30a8f
commit
9945b9eb7f
41 changed files with 2975 additions and 1376 deletions
77
src/config.rs
Normal file
77
src/config.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Tuple of how big thumbnails should be - (width, height)
|
||||
pub thumbnail_size: (u32, u32),
|
||||
/// Tuple of how big thumbnails should be - (width, height)
|
||||
pub view_size: (u32, u32),
|
||||
/// Directory inside the album that the site should be output to
|
||||
pub output_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_album(path: PathBuf) -> anyhow::Result<Config> {
|
||||
let config_path = path.join("photojawn.conf.yml");
|
||||
let content = fs::read(&config_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read config from {}. Is this an album directory?",
|
||||
config_path.display(),
|
||||
)
|
||||
})?;
|
||||
let cfg = serde_yml::from_slice(&content)
|
||||
.with_context(|| format!("Failed to parse config from {}", config_path.display()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
thumbnail_size: (256, 256),
|
||||
view_size: (1024, 768),
|
||||
output_dir: PathBuf::from("site"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::skel::make_skeleton;
|
||||
use mktemp::Temp;
|
||||
|
||||
#[test]
|
||||
fn test_default() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.thumbnail_size, (256, 256));
|
||||
assert_eq!(c.output_dir, PathBuf::from("site"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_yaml() {
|
||||
// Empty YAML gives full default values
|
||||
let default_cfg = Config::default();
|
||||
let cfg: Config = serde_yml::from_str("").unwrap();
|
||||
assert_eq!(cfg, default_cfg);
|
||||
|
||||
// Default values for any unspecified fields
|
||||
let cfg: Config = serde_yml::from_str("thumbnail_size: [1, 1]").unwrap();
|
||||
assert_ne!(cfg, default_cfg);
|
||||
assert_eq!(cfg.thumbnail_size, (1, 1));
|
||||
assert_eq!(cfg.view_size, default_cfg.view_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_base_album() {
|
||||
let tmpdir = Temp::new_dir().unwrap();
|
||||
make_skeleton(&tmpdir).unwrap();
|
||||
|
||||
let cfg = Config::from_album(tmpdir.to_path_buf()).unwrap();
|
||||
assert_eq!(cfg, Config::default());
|
||||
}
|
||||
}
|
||||
433
src/generate.rs
Normal file
433
src/generate.rs
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
pub mod album_dir;
|
||||
|
||||
use crate::config::Config;
|
||||
use album_dir::{AlbumDir, Image};
|
||||
use anyhow::{Context, anyhow};
|
||||
use image::imageops::FilterType;
|
||||
use indicatif::ProgressBar;
|
||||
use rayon::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tera::Tera;
|
||||
|
||||
const IMG_RESIZE_FILTER: FilterType = FilterType::Lanczos3;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct Breadcrumb {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// A Tera context for album pages
|
||||
#[derive(Serialize, Debug)]
|
||||
struct AlbumContext {
|
||||
// Path required to get back to the root album
|
||||
root_path: PathBuf,
|
||||
|
||||
name: String,
|
||||
description: String,
|
||||
|
||||
images: Vec<Image>,
|
||||
|
||||
// Path to the cover image thumbnail within /slides/, relative to the album dir. Used when
|
||||
// linking to an album from a parent album
|
||||
cover_thumbnail_path: PathBuf,
|
||||
|
||||
// list of:
|
||||
// - relative dir to walk up to root, e.g. "../../.."
|
||||
// - dir name
|
||||
breadcrumbs: Vec<Breadcrumb>,
|
||||
|
||||
// Immediate children of this album
|
||||
children: Vec<AlbumContext>,
|
||||
}
|
||||
|
||||
impl TryFrom<&AlbumDir> for AlbumContext {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(album: &AlbumDir) -> anyhow::Result<Self> {
|
||||
let name: String = match album.path.file_name() {
|
||||
Some(n) => n.to_string_lossy().to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Build breadcrumbs
|
||||
let mut breadcrumbs: Vec<Breadcrumb> = Vec::new();
|
||||
{
|
||||
let mut album_path = album.path.clone();
|
||||
let mut relpath: PathBuf = PathBuf::new();
|
||||
while album_path.pop() {
|
||||
let filename: &OsStr = album_path.file_name().unwrap_or(OsStr::new("Home"));
|
||||
relpath.push("..");
|
||||
breadcrumbs.push(Breadcrumb {
|
||||
path: relpath.clone(),
|
||||
name: filename.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
breadcrumbs.reverse();
|
||||
log::debug!("Crumbs for {}: {breadcrumbs:?}", album.path.display());
|
||||
|
||||
// The first breadcrumb path is the relative path to the root album
|
||||
let root_path = if !breadcrumbs.is_empty() {
|
||||
breadcrumbs[0].path.clone()
|
||||
} else {
|
||||
PathBuf::new()
|
||||
};
|
||||
|
||||
// Make the path to the thumbnail relative to the album that we're in
|
||||
let cover_thumbnail_path: PathBuf;
|
||||
if let Some(parent) = album.path.parent() {
|
||||
cover_thumbnail_path = album.cover.thumb_path.strip_prefix(parent)?.to_path_buf()
|
||||
} else {
|
||||
cover_thumbnail_path = album
|
||||
.cover
|
||||
.thumb_path
|
||||
.strip_prefix(&album.path)?
|
||||
.to_path_buf()
|
||||
};
|
||||
|
||||
let children: Vec<AlbumContext> = album
|
||||
.children
|
||||
.iter()
|
||||
.map(AlbumContext::try_from)
|
||||
.collect::<anyhow::Result<Vec<AlbumContext>>>()?;
|
||||
let images: Vec<Image> = album.images.clone();
|
||||
|
||||
Ok(AlbumContext {
|
||||
root_path,
|
||||
name,
|
||||
description: album.description.clone(),
|
||||
breadcrumbs,
|
||||
images,
|
||||
children,
|
||||
cover_thumbnail_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A Tera context for slide (individual image) pages
|
||||
#[derive(Serialize, Debug)]
|
||||
struct SlideContext {
|
||||
// Path required to get back to the root album
|
||||
root_path: PathBuf,
|
||||
image: Image,
|
||||
prev_image: Option<Image>,
|
||||
next_image: Option<Image>,
|
||||
}
|
||||
|
||||
/// Generate an album
|
||||
///
|
||||
/// `root_path` is a path to the root directory of the album. `full` if true will regenerate
|
||||
/// everything, including images that already exist.
|
||||
pub fn generate(root_path: &PathBuf, full: bool) -> anyhow::Result<PathBuf> {
|
||||
log::debug!("Generating album in {}", root_path.display());
|
||||
let config = Config::from_album(root_path.to_path_buf())?;
|
||||
let orig_path = env::current_dir()?;
|
||||
|
||||
// Jump into the root path so that all paths are relative to the root of the album
|
||||
env::set_current_dir(root_path)?;
|
||||
let album = AlbumDir::try_from(root_path)?;
|
||||
|
||||
fs::create_dir_all(&config.output_dir)?;
|
||||
copy_static(&config)?;
|
||||
generate_images(&config, &album, full)?;
|
||||
generate_html(&config, &album)?;
|
||||
|
||||
env::set_current_dir(orig_path)?;
|
||||
Ok(root_path.join(config.output_dir))
|
||||
}
|
||||
|
||||
fn copy_static(config: &Config) -> anyhow::Result<()> {
|
||||
let dst = &config.output_dir.join("static");
|
||||
log::info!("Copying static files from _static to {}", dst.display());
|
||||
fs_extra::dir::copy(
|
||||
"_static",
|
||||
dst,
|
||||
&fs_extra::dir::CopyOptions::new()
|
||||
.content_only(true)
|
||||
.overwrite(true),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Result<()> {
|
||||
let output_path = album.path.join(&config.output_dir);
|
||||
// HashSet is to avoid dupliates, which could happen since we add covers in later
|
||||
let mut all_images: HashSet<&Image> = album.iter_all_images().collect();
|
||||
|
||||
// also resize cover images, since we didn't count those as part of the image collections
|
||||
let mut album_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
|
||||
while let Some(album) = album_queue.pop_front() {
|
||||
all_images.insert(&album.cover);
|
||||
album_queue.extend(&album.children);
|
||||
}
|
||||
|
||||
let path_locks: HashMap<&PathBuf, Mutex<&PathBuf>> = all_images
|
||||
.iter()
|
||||
.map(|i| (&i.path, Mutex::new(&i.path)))
|
||||
.collect();
|
||||
|
||||
println!("Generating images...");
|
||||
let progress = ProgressBar::new(all_images.len() as u64);
|
||||
let result = all_images.par_iter().try_for_each(|img| {
|
||||
// Get the lock on the path to make sure two threads don't try to generate the same image
|
||||
// on disk.
|
||||
let _path_lock = path_locks.get(&img.path).unwrap().lock().unwrap();
|
||||
|
||||
let full_size_path = output_path.join(&img.path);
|
||||
if !full
|
||||
&& full_size_path.exists()
|
||||
&& fs::read(&full_size_path)? == fs::read(&full_size_path)?
|
||||
{
|
||||
log::info!("Skipping {}, already generated", img.path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Copying original {} -> {}",
|
||||
img.path.display(),
|
||||
full_size_path.display()
|
||||
);
|
||||
fs::create_dir_all(full_size_path.parent().unwrap_or(Path::new("")))?;
|
||||
if full_size_path.exists() {
|
||||
fs::remove_file(&full_size_path)?;
|
||||
}
|
||||
fs::hard_link(&img.path, &full_size_path)
|
||||
.with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?;
|
||||
|
||||
let orig_image = image::open(&img.path)?;
|
||||
let thumb_path = output_path.join(&img.thumb_path);
|
||||
log::info!(
|
||||
"Resizing {} -> {}",
|
||||
img.path.display(),
|
||||
thumb_path.display()
|
||||
);
|
||||
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
|
||||
orig_image
|
||||
.resize(
|
||||
config.thumbnail_size.0,
|
||||
config.thumbnail_size.1,
|
||||
IMG_RESIZE_FILTER,
|
||||
)
|
||||
.save(&thumb_path)
|
||||
.with_context(|| format!("Error saving {}", thumb_path.display()))?;
|
||||
|
||||
let screen_path = output_path.join(&img.screen_path);
|
||||
log::info!(
|
||||
"Resizing {} -> {}",
|
||||
img.path.display(),
|
||||
screen_path.display()
|
||||
);
|
||||
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
|
||||
orig_image
|
||||
.resize(config.view_size.0, config.view_size.1, IMG_RESIZE_FILTER)
|
||||
.save(&screen_path)
|
||||
.with_context(|| format!("Error saving {}", screen_path.display()))?;
|
||||
|
||||
progress.inc(1);
|
||||
Ok(())
|
||||
});
|
||||
progress.finish();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
|
||||
let output_path = album.path.join(&config.output_dir);
|
||||
let tera = Tera::new(
|
||||
album
|
||||
.path
|
||||
.join("_templates/*.html")
|
||||
.to_str()
|
||||
.ok_or(anyhow!("Missing _templates dir in album dir"))?,
|
||||
)?;
|
||||
|
||||
println!("Generating HTML...");
|
||||
|
||||
let mut dir_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
|
||||
while let Some(album) = dir_queue.pop_front() {
|
||||
let html_path = output_path.join(&album.path).join("index.html");
|
||||
log::info!("Rendering album page {}", html_path.display());
|
||||
let ctx = AlbumContext::try_from(album)?;
|
||||
log::debug!("Album context: {ctx:?}");
|
||||
fs::write(
|
||||
output_path.join(&album.path).join("index.html"),
|
||||
tera.render("album.html", &tera::Context::from_serialize(&ctx)?)?,
|
||||
)?;
|
||||
|
||||
for child in album.children.iter() {
|
||||
dir_queue.push_back(child);
|
||||
}
|
||||
|
||||
for (pos, img) in album.images.iter().enumerate() {
|
||||
let prev_image: Option<&Image> = match pos {
|
||||
0 => None,
|
||||
n => Some(&album.images[n - 1]),
|
||||
};
|
||||
let next_image: Option<&Image> = album.images.get(pos + 1);
|
||||
|
||||
// Find the path to the root by counting the parts of the path
|
||||
// Start with 1 .. to get out of the slides dir
|
||||
let mut path_to_root = PathBuf::from("..");
|
||||
if let Some(parent) = img.path.parent() {
|
||||
let mut parent = parent.to_path_buf();
|
||||
while parent.pop() {
|
||||
path_to_root.push("..");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Rendering image page {}", img.html_path.display());
|
||||
let ctx = SlideContext {
|
||||
root_path: path_to_root,
|
||||
image: img.clone(),
|
||||
prev_image: prev_image.cloned(),
|
||||
next_image: next_image.cloned(),
|
||||
};
|
||||
log::debug!("Image context: {ctx:?}");
|
||||
fs::write(
|
||||
output_path.join(&img.html_path),
|
||||
tera.render("photo.html", &tera::Context::from_serialize(&ctx)?)?,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::generate;
|
||||
use crate::skel::make_skeleton;
|
||||
use mktemp::Temp;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
/// Test that the generate function creates a rendered site as we expect it
|
||||
fn test_generate() {
|
||||
init();
|
||||
let album_path = make_test_album();
|
||||
let output_path = generate(&album_path.to_path_buf(), false).unwrap();
|
||||
|
||||
check_album(output_path).unwrap();
|
||||
}
|
||||
|
||||
fn init() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
}
|
||||
|
||||
/// Copies the test album to a tempdir and returns the path to it
|
||||
fn make_test_album() -> Temp {
|
||||
let tmpdir = Temp::new_dir().unwrap();
|
||||
let source_path = Path::new("resources/test_album");
|
||||
|
||||
log::info!("Creating test album in {}", tmpdir.display());
|
||||
make_skeleton(&tmpdir.to_path_buf()).unwrap();
|
||||
fs_extra::dir::copy(
|
||||
&source_path,
|
||||
&tmpdir,
|
||||
&fs_extra::dir::CopyOptions::new().content_only(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tmpdir
|
||||
}
|
||||
|
||||
/// Does basic sanity checks on an output album
|
||||
fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
|
||||
log::debug!("Checking album dir {}", root_path.display());
|
||||
|
||||
// The _static dir should have gotten copied into <output>/static
|
||||
assert!(root_path.join("static/index.css").exists());
|
||||
|
||||
let mut dirs: VecDeque<PathBuf> = VecDeque::from([root_path.clone()]);
|
||||
while let Some(dir) = dirs.pop_front() {
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
for entry in dir.read_dir().unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir()
|
||||
&& !path.ends_with(Path::new("slides"))
|
||||
&& path.file_name() != Some(OsStr::new("static"))
|
||||
{
|
||||
dirs.push_back(path.clone());
|
||||
} else if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
// There should be an index.html
|
||||
let index_path = dir.join("index.html");
|
||||
assert!(
|
||||
index_path.exists(),
|
||||
"Expected {} to exist",
|
||||
index_path.display()
|
||||
);
|
||||
|
||||
// There should be a slides dir
|
||||
let slides_path = dir.join("slides");
|
||||
assert!(
|
||||
slides_path.is_dir(),
|
||||
"Expected {} to be a dir",
|
||||
slides_path.display()
|
||||
);
|
||||
|
||||
// No two images should have the same path
|
||||
let image_set: HashSet<&PathBuf> = files.iter().collect();
|
||||
assert_eq!(image_set.len(), files.len());
|
||||
|
||||
// For each image in the album (including the cover), in slides there should be a:
|
||||
// - <image>.html
|
||||
// - <image>.screen.<ext>
|
||||
// - <image>.thumb.<ext>
|
||||
for file in &files {
|
||||
if let Some(ext) = file.extension() {
|
||||
if ext != "jpg" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::debug!("Checking associated files for {}", file.display());
|
||||
|
||||
if !file
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("cover")
|
||||
{
|
||||
let html_path =
|
||||
slides_path.join(&file.with_extension("html").file_name().unwrap());
|
||||
assert!(
|
||||
html_path.exists(),
|
||||
"Expected {} to exist",
|
||||
html_path.display()
|
||||
);
|
||||
}
|
||||
for ext in ["screen.jpg", "thumb.jpg"] {
|
||||
let img_path = slides_path.join(file.with_extension(ext).file_name().unwrap());
|
||||
assert!(
|
||||
img_path.exists(),
|
||||
"Expected {} to exist",
|
||||
img_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// There shouldn't be any .txt or .md files hanging around
|
||||
for file in &files {
|
||||
if let Some(ext) = file.extension() {
|
||||
assert_ne!(ext, "md");
|
||||
assert_ne!(ext, "txt");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
330
src/generate/album_dir.rs
Normal file
330
src/generate/album_dir.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
use anyhow::anyhow;
|
||||
use image::ImageReader;
|
||||
use serde::Serialize;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::slice::Iter;
|
||||
|
||||
/// An album directory, which has images and possibly child albums
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct AlbumDir {
|
||||
pub path: PathBuf,
|
||||
pub images: Vec<Image>,
|
||||
pub cover: Image,
|
||||
pub children: Vec<AlbumDir>,
|
||||
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl AlbumDir {
|
||||
/// Returns an iterator over all images in the album and subalbums
|
||||
pub fn iter_all_images(&self) -> AlbumImageIter {
|
||||
AlbumImageIter::new(self)
|
||||
}
|
||||
|
||||
/// Create an AlbumDir recursively from a path. The root path is so that we can make every path
|
||||
/// relative to the root.
|
||||
fn from_path(p: &Path, root: &Path) -> anyhow::Result<Self> {
|
||||
let mut images = vec![];
|
||||
let mut cover: Option<Image> = None;
|
||||
let mut children = vec![];
|
||||
let mut description = String::new();
|
||||
|
||||
for entry in p.read_dir()? {
|
||||
let entry_path = entry?.path().to_path_buf();
|
||||
|
||||
if entry_path.is_file() {
|
||||
if let Some(filename) = entry_path.file_name() {
|
||||
if filename == "description.txt" {
|
||||
description = fs::read_to_string(entry_path)?;
|
||||
} else if filename == "description.md" {
|
||||
log::debug!("Loading Markdown from {}", entry_path.display());
|
||||
let contents = fs::read_to_string(&entry_path)?;
|
||||
let parser = pulldown_cmark::Parser::new(&contents);
|
||||
pulldown_cmark::html::push_html(&mut description, parser);
|
||||
} else {
|
||||
if filename.to_string_lossy().starts_with("cover") {
|
||||
log::debug!("Found explicit cover for {}", p.display());
|
||||
cover = Some(Image::new(
|
||||
entry_path.strip_prefix(root)?.to_path_buf(),
|
||||
String::new(),
|
||||
)?);
|
||||
// Don't include the cover in the set of images
|
||||
continue;
|
||||
}
|
||||
|
||||
let reader = ImageReader::open(&entry_path)?.with_guessed_format()?;
|
||||
if reader.format().is_some() {
|
||||
// Found an image
|
||||
let mut description = String::new();
|
||||
|
||||
// Read in any associated description file
|
||||
if entry_path.with_extension("txt").exists() {
|
||||
description = fs::read_to_string(entry_path.with_extension("txt"))?;
|
||||
} else if entry_path.with_extension("md").exists() {
|
||||
log::debug!(
|
||||
"Loading Markdown from {}",
|
||||
entry_path.with_extension("md").display()
|
||||
);
|
||||
let contents = fs::read_to_string(entry_path.with_extension("md"))?;
|
||||
let parser = pulldown_cmark::Parser::new(&contents);
|
||||
pulldown_cmark::html::push_html(&mut description, parser);
|
||||
}
|
||||
|
||||
images.push(Image::new(
|
||||
entry_path.strip_prefix(root)?.to_path_buf(),
|
||||
description,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if entry_path.is_dir() {
|
||||
if let Some(dirname) = entry_path.file_name().and_then(|n| n.to_str()) {
|
||||
if dirname.starts_with("_") {
|
||||
// Likely a templates or static dir
|
||||
continue;
|
||||
} else if dirname == "site" {
|
||||
// Is a generated site dir, don't descend into it
|
||||
continue;
|
||||
} else if dirname == "slides" {
|
||||
continue;
|
||||
}
|
||||
|
||||
children.push(AlbumDir::from_path(&entry_path, root)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
children.sort_by_key(|c| c.path.clone());
|
||||
images.sort_by_key(|i| i.path.clone());
|
||||
|
||||
// Find a cover image if we didn't have an explicit one. Either the first image, or the
|
||||
// first image from the first album that has a cover.
|
||||
if cover.is_none() {
|
||||
if !images.is_empty() {
|
||||
cover = Some(images[0].clone());
|
||||
} else {
|
||||
// Find a cover image from one of the children
|
||||
if !children.is_empty() {
|
||||
cover = Some(children[0].cover.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let cover = cover.ok_or(anyhow!("Could not find a cover image for {}", p.display()))?;
|
||||
log::debug!("Cover for {} is {}", p.display(), cover.path.display());
|
||||
|
||||
Ok(AlbumDir {
|
||||
path: p.strip_prefix(root)?.to_path_buf(),
|
||||
images,
|
||||
cover,
|
||||
children,
|
||||
description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&PathBuf> for AlbumDir {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(p: &PathBuf) -> anyhow::Result<AlbumDir> {
|
||||
AlbumDir::from_path(p, p)
|
||||
}
|
||||
}
|
||||
|
||||
/// An iterator which walks through all of the images in an album, and its sub-albums
|
||||
pub struct AlbumImageIter<'a> {
|
||||
image_iter: Box<dyn Iterator<Item = &'a Image> + 'a>,
|
||||
children_iter: Iter<'a, AlbumDir>,
|
||||
}
|
||||
|
||||
impl<'a> AlbumImageIter<'a> {
|
||||
fn new(ad: &'a AlbumDir) -> Self {
|
||||
Self {
|
||||
image_iter: Box::new(ad.images.iter()),
|
||||
children_iter: ad.children.iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AlbumImageIter<'a> {
|
||||
type Item = &'a Image;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(img) = self.image_iter.next() {
|
||||
return Some(img);
|
||||
}
|
||||
|
||||
for album in self.children_iter.by_ref() {
|
||||
// Set the child album as the current image iterator
|
||||
self.image_iter = Box::new(album.iter_all_images());
|
||||
// If we found a child album with an image, return the image. Otherwise we'll keep
|
||||
// iterating over children.
|
||||
if let Some(i) = self.image_iter.next() {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)]
|
||||
pub struct Image {
|
||||
/// Path to the image, relative to the root album
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
|
||||
/// Text description of the image which is displayed below it on the HTML page
|
||||
pub description: String,
|
||||
|
||||
pub thumb_filename: String,
|
||||
pub thumb_path: PathBuf,
|
||||
pub screen_filename: String,
|
||||
pub screen_path: PathBuf,
|
||||
pub html_filename: String,
|
||||
pub html_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub fn new(path: PathBuf, description: String) -> anyhow::Result<Self> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or(anyhow!(
|
||||
"Image path {} is missing a filename",
|
||||
path.display()
|
||||
))?
|
||||
.to_str()
|
||||
.ok_or(anyhow!("Cannot convert {} to a string", path.display()))?
|
||||
.to_string();
|
||||
let thumb_filename = Self::slide_filename(&path, "thumb", true)?;
|
||||
let thumb_path = Self::slide_path(&path, &thumb_filename);
|
||||
let screen_filename = Self::slide_filename(&path, "screen", true)?;
|
||||
let screen_path = Self::slide_path(&path, &screen_filename);
|
||||
let html_filename = Self::slide_filename(&path, "html", false)?;
|
||||
let html_path = Self::slide_path(&path, &html_filename);
|
||||
|
||||
Ok(Image {
|
||||
path,
|
||||
description,
|
||||
filename,
|
||||
thumb_filename,
|
||||
thumb_path,
|
||||
screen_filename,
|
||||
screen_path,
|
||||
html_filename,
|
||||
html_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the filename for a given slide type. For example if ext = "thumb" and the current
|
||||
/// filename is "blah.jpg" this will return "blah.thumb.jpg". If keep_ext if false, it would
|
||||
/// return "blah.thumb"
|
||||
fn slide_filename(path: &Path, ext: &str, keep_ext: bool) -> anyhow::Result<String> {
|
||||
let mut new_ext: OsString = ext.into();
|
||||
if keep_ext {
|
||||
if let Some(e) = path.extension() {
|
||||
new_ext = OsString::from(
|
||||
ext.to_string()
|
||||
+ "."
|
||||
+ e.to_str().ok_or(anyhow!(
|
||||
"Image {} extension is not valid UTF-8",
|
||||
path.display()
|
||||
))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let new_path = path.with_extension(new_ext);
|
||||
let new_name = new_path
|
||||
.file_name()
|
||||
.ok_or(anyhow!("Image {} missing a file name", path.display()))?
|
||||
.to_str()
|
||||
.ok_or(anyhow!("Unable to convert {} to a string", path.display()))?
|
||||
.to_string();
|
||||
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
/// Returns the path to the file in the slides dir given the path to the original image
|
||||
fn slide_path(path: &Path, file_name: &str) -> PathBuf {
|
||||
let mut new_path = path.to_path_buf();
|
||||
new_path.pop();
|
||||
new_path.push("slides");
|
||||
new_path.push(file_name);
|
||||
new_path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn basic_album_iter() {
|
||||
let mut ad = AlbumDir {
|
||||
path: "".into(),
|
||||
description: "".to_string(),
|
||||
cover: Image::new("foo".into(), "".to_string()).unwrap(),
|
||||
images: vec![
|
||||
Image::new("foo".into(), "".to_string()).unwrap(),
|
||||
Image::new("bar".into(), "".to_string()).unwrap(),
|
||||
],
|
||||
children: vec![],
|
||||
};
|
||||
// A child album with some images
|
||||
ad.children.push(AlbumDir {
|
||||
path: "subdir".into(),
|
||||
description: "".to_string(),
|
||||
cover: Image::new("subdir/foo".into(), "".to_string()).unwrap(),
|
||||
images: vec![
|
||||
Image::new("subdir/foo".into(), "".to_string()).unwrap(),
|
||||
Image::new("subdir/bar".into(), "".to_string()).unwrap(),
|
||||
],
|
||||
children: vec![AlbumDir {
|
||||
path: "subdir/deeper_subdir".into(),
|
||||
description: "".to_string(),
|
||||
cover: Image::new("subdir/deeper_subdir/image.jpg".into(), "".to_string()).unwrap(),
|
||||
images: vec![
|
||||
Image::new("subdir/deeper_subdir/image.jpg".into(), String::new()).unwrap(),
|
||||
],
|
||||
children: vec![],
|
||||
}],
|
||||
});
|
||||
// A child album with no images
|
||||
ad.children.push(AlbumDir {
|
||||
description: "".to_string(),
|
||||
cover: Image::new("blah".into(), "".to_string()).unwrap(),
|
||||
path: "another_subdir".into(),
|
||||
images: vec![],
|
||||
children: vec![],
|
||||
});
|
||||
|
||||
let imgs: HashSet<&str> = ad
|
||||
.iter_all_images()
|
||||
.map(|i| i.path.to_str().unwrap())
|
||||
.collect();
|
||||
let expected: HashSet<&str> = HashSet::from([
|
||||
"foo",
|
||||
"bar",
|
||||
"subdir/foo",
|
||||
"subdir/bar",
|
||||
"subdir/deeper_subdir/image.jpg",
|
||||
]);
|
||||
assert_eq!(imgs, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_paths() {
|
||||
let img = Image::new(PathBuf::from("foo/bar/image.jpg"), String::new()).unwrap();
|
||||
assert_eq!(
|
||||
img.thumb_path,
|
||||
PathBuf::from("foo/bar/slides/image.thumb.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
img.screen_path,
|
||||
PathBuf::from("foo/bar/slides/image.screen.jpg")
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/lib.rs
Normal file
3
src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod config;
|
||||
pub mod generate;
|
||||
pub mod skel;
|
||||
47
src/main.rs
Normal file
47
src/main.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use clap::{Parser, Subcommand};
|
||||
use photojawn::generate::generate;
|
||||
use photojawn::skel::make_skeleton;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let album_path = Path::new(&cli.album_path).canonicalize()?;
|
||||
|
||||
match cli.subcommand {
|
||||
Commands::Init {} => {
|
||||
make_skeleton(&album_path)?;
|
||||
println!("Album created in {}", album_path.display());
|
||||
}
|
||||
Commands::Generate { full } => {
|
||||
let path = generate(&album_path.to_path_buf(), full)?;
|
||||
println!("Album site generated in {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to the album
|
||||
#[arg(long, default_value = ".")]
|
||||
album_path: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
subcommand: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize a new Photojawn album directory
|
||||
Init {},
|
||||
/// Generates a photo album
|
||||
Generate {
|
||||
/// Regenerate everything, including images that have already been generated
|
||||
#[arg(long)]
|
||||
full: bool,
|
||||
},
|
||||
}
|
||||
92
src/skel.rs
Normal file
92
src/skel.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InitError {
|
||||
#[error("Album directory already initialized - contains a {0}")]
|
||||
AlreadyInitialized(PathBuf),
|
||||
#[error(transparent)]
|
||||
IoError(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Creates a new album directory and creates basic versions
|
||||
pub fn make_skeleton(album_path: &Path) -> Result<(), InitError> {
|
||||
let files = HashMap::from([
|
||||
(
|
||||
album_path.join("photojawn.conf.yml"),
|
||||
include_bytes!("../resources/skel/photojawn.conf.yml").as_slice(),
|
||||
),
|
||||
(
|
||||
album_path.join("_static/index.css"),
|
||||
include_bytes!("../resources/skel/_static/index.css").as_slice(),
|
||||
),
|
||||
(
|
||||
album_path.join("_templates/base.html"),
|
||||
include_bytes!("../resources/skel/_templates/base.html").as_slice(),
|
||||
),
|
||||
(
|
||||
album_path.join("_templates/album.html"),
|
||||
include_bytes!("../resources/skel/_templates/album.html").as_slice(),
|
||||
),
|
||||
(
|
||||
album_path.join("_templates/photo.html"),
|
||||
include_bytes!("../resources/skel/_templates/photo.html").as_slice(),
|
||||
),
|
||||
]);
|
||||
|
||||
// Bail if any of the files we would create exist
|
||||
for path in files.keys() {
|
||||
if path.exists() {
|
||||
return Err(InitError::AlreadyInitialized(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(album_path)?;
|
||||
for (path, contents) in files.iter() {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, contents)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mktemp::Temp;
|
||||
|
||||
#[test]
|
||||
fn not_exist() {
|
||||
let tmpdir = Temp::new_dir().unwrap();
|
||||
make_skeleton(&tmpdir).unwrap();
|
||||
assert!(tmpdir.join("photojawn.conf.yml").exists());
|
||||
assert!(tmpdir.join("_static/index.css").exists());
|
||||
assert!(tmpdir.join("_templates/base.html").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_exists() {
|
||||
let tmpdir = Temp::new_dir().unwrap();
|
||||
fs::write(tmpdir.join("photojawn.conf.yml"), "some: config").unwrap();
|
||||
let res = make_skeleton(&tmpdir);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_exists_no_config() {
|
||||
let tmpdir = Temp::new_dir().unwrap();
|
||||
fs::create_dir(tmpdir.join("_templates")).unwrap();
|
||||
fs::write(tmpdir.join("_templates/base.html"), "some template").unwrap();
|
||||
let res = make_skeleton(&tmpdir);
|
||||
assert!(res.is_err());
|
||||
|
||||
// Make sure it didn't clobber our template
|
||||
let contents = fs::read(tmpdir.join("_templates/base.html")).unwrap();
|
||||
assert_eq!(contents, "some template".as_bytes());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue