Compare commits

..

8 commits

Author SHA1 Message Date
9ae778bb79 bump version to 0.2.0 and update deps
Some checks failed
Rust / build (push) Has been cancelled
2025-05-19 10:22:24 -07:00
5ff3338b30 Fix dep spec, no need for caret
Some checks are pending
Rust / build (push) Waiting to run
2025-05-18 08:52:26 -07:00
b1d66d7e9f bump version
Some checks are pending
Rust / build (push) Waiting to run
2025-05-18 08:47:59 -07:00
aba9fa4025
Reorganize command (#4)
Some checks are pending
Rust / build (push) Waiting to run
Adds a command to reorganize a folder of photos, renaming them so that
they contain date and time so that they're sorted by that.

This also renames files associated with the photos, like the
descriptions, like IMG_1234.jpg with IMG_1234.md
2025-05-18 08:46:41 -07:00
37581ee6a0 fix .gitignore
Some checks failed
Rust / build (push) Has been cancelled
2025-05-11 14:44:51 -07:00
aa57c0d092 tweaks
Some checks failed
Rust / build (push) Has been cancelled
2025-05-10 08:49:11 -07:00
4ebaee95cc reorganize 2025-05-10 08:49:03 -07:00
9945b9eb7f
Rewrite in Rust (#3)
Some checks are pending
Rust / build (push) Waiting to run
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.
2025-05-08 12:27:49 -07:00
14 changed files with 621 additions and 274 deletions

2
.gitignore vendored
View file

@ -7,6 +7,6 @@ dist
/target
# Project specific files
test_album*
/test_album*
DESIGN.md
TODO.md

115
Cargo.lock generated
View file

@ -145,9 +145,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "bitstream-io"
@ -200,9 +200,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cc"
version = "1.2.21"
version = "1.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0"
checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766"
dependencies = [
"jobserver",
"libc",
@ -227,9 +227,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.37"
version = "4.5.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
dependencies = [
"clap_builder",
"clap_derive",
@ -237,9 +237,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.37"
version = "4.5.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
dependencies = [
"anstream",
"anstyle",
@ -349,6 +349,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -472,9 +481,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.3.2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
@ -511,7 +520,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"ignore",
"walkdir",
]
@ -528,9 +537,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.15.2"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
[[package]]
name = "heck"
@ -650,9 +659,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jiff"
version = "0.2.12"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d07d8d955d798e7a4d6f9c58cd1f1916e790b42b092758a9ef6e16fef9f1b3fd"
checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806"
dependencies = [
"jiff-static",
"log",
@ -663,9 +672,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.12"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f244cfe006d98d26f859c7abd1318d85327e1882dc9cef80f62daeeb0adcf300"
checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48"
dependencies = [
"proc-macro2",
"quote",
@ -678,7 +687,7 @@ version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.2",
"getrandom 0.3.3",
"libc",
]
@ -698,6 +707,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kamadak-exif"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
dependencies = [
"mutate_once",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -792,6 +810,12 @@ dependencies = [
"uuid",
]
[[package]]
name = "mutate_once"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -824,6 +848,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
@ -929,7 +959,7 @@ dependencies = [
[[package]]
name = "photojawn"
version = "0.2.0-pre.1"
version = "0.2.0"
dependencies = [
"anyhow",
"clap",
@ -937,6 +967,7 @@ dependencies = [
"fs_extra",
"image",
"indicatif",
"kamadak-exif",
"log",
"mktemp",
"pulldown-cmark",
@ -945,6 +976,7 @@ dependencies = [
"serde_yml",
"tera",
"thiserror 2.0.12",
"time",
]
[[package]]
@ -981,6 +1013,12 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1024,7 +1062,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
"getopts",
"memchr",
"pulldown-cmark-escape",
@ -1420,6 +1458,37 @@ dependencies = [
"weezl",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "toml"
version = "0.8.22"
@ -1761,9 +1830,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3"
checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]
@ -1774,7 +1843,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.0",
"bitflags 2.9.1",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "photojawn"
version = "0.2.0-pre.1"
version = "0.2.0"
description = "A static site generator for photo albums"
authors = ["Nick Pegg <nick@nickpegg.com>"]
license = "MIT"
@ -8,19 +8,21 @@ repository = "https://github.com/nickpegg/photojawn"
edition = "2024"
[dependencies]
anyhow = "^1.0"
clap = { version = "^4.5", features = ["derive"] }
env_logger = "^0.11.8"
fs_extra = "^1.3.0"
image = "^0.25.6"
indicatif = "^0.17.11"
log = "^0.4.27"
pulldown-cmark = "^0.13.0"
rayon = "^1.10.0"
serde = { version = "^1.0", features = ["derive"] }
serde_yml = "^0.0.12"
tera = { version = "^1.20", default-features = false }
thiserror = "^2.0"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
env_logger = "0.11.8"
fs_extra = "1.3"
image = "0.25.6"
indicatif = "0.17.11"
kamadak-exif = "0.6.1"
log = "0.4.27"
pulldown-cmark = "0.13.0"
rayon = "1.10"
serde = { version = "1.0", features = ["derive"] }
serde_yml = "0.0.12"
tera = { version = "1.20", default-features = false }
thiserror = "2.0"
time = { version = "0.3.41", features = ["formatting", "macros", "parsing"] }
[dev-dependencies]
mktemp = "^0.5.1"
mktemp = "0.5.1"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -1,9 +1,10 @@
pub mod album_dir;
mod image;
use crate::config::Config;
use album_dir::{AlbumDir, Image};
use crate::generate::image::Image;
use album_dir::AlbumDir;
use anyhow::{Context, anyhow};
use image::imageops::FilterType;
use indicatif::ProgressBar;
use rayon::prelude::*;
use serde::Serialize;
@ -16,112 +17,7 @@ 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>,
}
const IMG_RESIZE_FILTER: ::image::imageops::FilterType = ::image::imageops::FilterType::Lanczos3;
/// Generate an album
///
/// `root_path` is a path to the root directory of the album. `full` if true will regenerate
@ -202,7 +98,8 @@ fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Res
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 orig_image = ::image::open(&img.path)
.with_context(|| format!("Failed to read image {}", &img.path.display()))?;
let thumb_path = output_path.join(&img.thumb_path);
log::info!(
"Resizing {} -> {}",
@ -246,7 +143,7 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
.path
.join("_templates/*.html")
.to_str()
.ok_or(anyhow!("Missing _templates dir in album dir"))?,
.ok_or(anyhow!("Album path {} is invalid", album.path.display()))?,
)?;
println!("Generating HTML...");
@ -301,15 +198,118 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
Ok(())
}
#[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 = match breadcrumbs.is_empty() {
false => breadcrumbs[0].path.clone(),
true => 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>,
}
#[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};
use crate::test_util::{init, make_test_album};
#[test]
/// Test that the generate function creates a rendered site as we expect it
fn test_generate() {
@ -320,27 +320,6 @@ mod tests {
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());

View file

@ -1,7 +1,7 @@
use crate::generate::image::Image;
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;
@ -169,93 +169,6 @@ impl<'a> Iterator for AlbumImageIter<'a> {
}
}
#[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::*;
@ -314,17 +227,4 @@ mod tests {
]);
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")
);
}
}

108
src/generate/image.rs Normal file
View file

@ -0,0 +1,108 @@
use anyhow::anyhow;
use serde::Serialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[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::*;
#[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")
);
}
}

View file

@ -1,3 +1,7 @@
pub mod config;
pub(crate) mod config;
pub mod generate;
pub mod reorganize;
pub mod skel;
#[cfg(test)]
pub(crate) mod test_util;

View file

@ -1,5 +1,6 @@
use clap::{Parser, Subcommand};
use photojawn::generate::generate;
use photojawn::reorganize::reorganize;
use photojawn::skel::make_skeleton;
use std::path::Path;
@ -18,6 +19,9 @@ fn main() -> anyhow::Result<()> {
let path = generate(&album_path.to_path_buf(), full)?;
println!("Album site generated in {}", path.display());
}
Commands::Reorganize { path, dry_run } => {
reorganize(Path::new(&path), dry_run)?;
}
}
Ok(())
@ -44,4 +48,17 @@ enum Commands {
#[arg(long)]
full: bool,
},
/// Reorganize photos in an album by date
Reorganize {
/// Directory of images you want to reorganize. Only image files will be moved.
///
/// The new image filenames will be the date and time taken, followed by the original
/// filename. For example:
/// original_filename.jpg -> YYYYMMDD_HHSS_original_filename.jpg
#[arg()]
path: String,
/// Don't actually reorganize, just say what renames would happen
#[arg(long)]
dry_run: bool,
},
}

243
src/reorganize.rs Normal file
View file

@ -0,0 +1,243 @@
use anyhow::{anyhow, Context};
use image::ImageReader;
use std::ffi::OsStr;
use std::fs::{rename, File};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::str::from_utf8;
use thiserror::Error;
use time::macros::format_description;
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime};
#[derive(Error, Debug)]
pub enum OrganizeError {
#[error("These files are not supported, unable to parse EXIF data: {0:?}")]
ExifNotSupported(Vec<PathBuf>),
#[error("File {0} is missing an EXIF DateTimeOriginal field")]
ExifNoDateTime(PathBuf),
}
pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> {
let renames = get_renames(dir)?;
if renames.is_empty() {
println!("Nothing to rename");
return Ok(());
}
// Either do the renames, or if dry-run print what the names would be
if dry_run {
for (src, dst) in renames {
println!("{} -> {}", src.display(), dst.display());
}
println!("Would have renamed the above files");
} else {
for (src, dst) in renames {
println!("{} -> {}", src.display(), dst.display());
rename(&src, &dst).with_context(|| {
format!("Failed to rename {} to {}", src.display(), dst.display())
})?;
}
}
Ok(())
}
/// Returns a vec of tuples of all the renames that need to happen in a directory
fn get_renames(dir: &Path) -> anyhow::Result<Vec<(PathBuf, PathBuf)>> {
let mut renames: Vec<(PathBuf, PathBuf)> = Vec::new();
// Run through all the images and figure out new names for them
for entry in dir.read_dir()? {
let entry = entry?;
if !entry.path().is_file() {
continue;
}
// Only bother with image files, because those are the only hope for EXIF
let is_image: bool = ImageReader::open(entry.path())?
.with_guessed_format()?
.format()
.is_some();
let is_cover: bool = entry
.path()
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("cover"));
if is_image && !is_cover {
// TODO: Should we just skip over images with no EXIF data? Find datetime some other
// way?
let Ok(dt) = get_exif_datetime(entry.path()) else {
log::warn!(
"Unable to read datetime from EXIF for {}",
entry.path().display()
);
continue;
};
let orig_filename = entry
.path()
.file_name()
.unwrap_or(OsStr::new(""))
.to_string_lossy()
.into_owned();
let ext = entry
.path()
.extension()
.ok_or(anyhow!(
"{} is missing an extension",
entry.path().display()
))?
.to_string_lossy()
.to_string();
let new_filename_base = dt.format(format_description!(
"[year][month][day]_[hour][minute][second]_"
))?;
// Renaming an already-renamed file should be a no-op
if orig_filename.starts_with(&new_filename_base) {
log::info!("{orig_filename} looks like it was already renamed, skiping");
continue;
}
let new_path = entry
.path()
.with_file_name(new_filename_base + &orig_filename)
.with_extension(ext);
renames.push((entry.path(), new_path.clone()));
// Check for files associated with this image and set them up to be renamed too, like
// description files that end with .txt or .md
for ext in ["txt", "md"] {
let side_file_path = entry.path().with_extension(ext);
if side_file_path.exists() {
let new_side_file_path = new_path.with_extension(ext);
renames.push((side_file_path, new_side_file_path));
}
}
}
}
// Sort renames by the destination
renames.sort_by_key(|(_, dst)| dst.clone());
Ok(renames)
}
/// Tries to figure out the datetime that the image was created from EXIF metadata
fn get_exif_datetime(path: PathBuf) -> anyhow::Result<UtcDateTime> {
let format_with_offset = format_description!(
"[year]:[month]:[day] [hour]:[minute]:[second][offset_hour]:[offset_minute]"
);
let format_without_offset =
format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]");
let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?;
let mut bufreader = BufReader::new(file);
let exif = exif::Reader::new()
.read_from_container(&mut bufreader)
.with_context(|| format!("Couldn't read EXIF data from {}", path.display()))?;
let field = exif
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
.ok_or(OrganizeError::ExifNoDateTime(path.clone()))?;
let dt: UtcDateTime = match &field.value {
exif::Value::Ascii(v) => {
let s = from_utf8(&v[0])?;
log::debug!("Date string from file: {s}");
match OffsetDateTime::parse(s, format_with_offset) {
Ok(v) => v.to_utc(),
Err(_) => PrimitiveDateTime::parse(s, format_without_offset)?.as_utc(),
}
}
_ => return Err(OrganizeError::ExifNoDateTime(path).into()),
};
Ok(dt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{init, make_test_album};
use time::{Date, Month, Time};
#[test]
/// Make sure we can get the datetime from one of our test photos
fn basic_datetime_read() {
init();
let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap();
log::info!("Got dt: {dt}");
assert_eq!(
dt,
UtcDateTime::new(
Date::from_calendar_date(1970, Month::January, 1).unwrap(),
Time::from_hms(13, 37, 0).unwrap(),
)
)
}
#[test]
fn exif_datetime_missing() {
init();
let result = get_exif_datetime("resources/test_album/mountains.jpg".into());
assert!(result.is_err());
//result.unwrap();
}
#[test]
fn test_basic_renames() {
init();
let tmp_album_dir = make_test_album();
let dir = tmp_album_dir.join("with_description");
log::debug!("Getting renames for {}", dir.display());
let renames = get_renames(&dir).unwrap();
assert_eq!(
renames,
vec![
(dir.join("moon.jpg"), dir.join("19700102_133700_moon.jpg")),
(dir.join("moon.txt"), dir.join("19700102_133700_moon.txt")),
(
dir.join("mountains.jpg"),
dir.join("19700103_133700_mountains.jpg")
),
]
);
}
#[test]
/// get_renames() should ignore other stuff in the directory
fn test_other_junk() {
init();
let tmp_album_dir = make_test_album();
let renames = get_renames(&tmp_album_dir).unwrap();
// No mountain.jpg since it doesn't have EXIF data
assert_eq!(
renames,
vec![(
tmp_album_dir.join("moon.jpg"),
tmp_album_dir.join("19700101_133700_moon.jpg")
)]
);
}
#[test]
/// The rename function will prepend date and time to the original filenames. If we do it a
/// second time, it should be a no-op instead of continuing to prepend date and time.
fn test_rerename() {
let tmp_album_dir = make_test_album();
let dir = tmp_album_dir.join("with_description");
reorganize(&dir, false).unwrap();
let renames = get_renames(&dir).unwrap();
assert_eq!(renames, Vec::new());
}
}

25
src/test_util.rs Normal file
View file

@ -0,0 +1,25 @@
use crate::skel::make_skeleton;
use mktemp::Temp;
use std::path::Path;
pub fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
/// Copies the test album to a tempdir and returns the path to it. Returns a Temp object which
/// cleans up the directory on drop, so make sure to persist the variable until you're done with it
pub fn make_test_album() -> Temp {
let tmpdir = Temp::new_dir().unwrap();
let source_path = Path::new("resources/test_album").canonicalize().unwrap();
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
}