Compare commits

..

54 commits

Author SHA1 Message Date
1066367515 even less verbose 2025-05-08 12:25:14 -07:00
d2e077ef2d shush 2025-05-08 12:23:09 -07:00
16b1d6f29d MFer said cippy 2025-05-08 12:21:47 -07:00
f00778d6cd fix up Makefile, comment out old crap 2025-05-08 12:18:24 -07:00
fca9283f6f remove old python stuff 2025-05-08 12:17:39 -07:00
f225b4cd5b add GH workflow for rust 2025-05-08 12:17:21 -07:00
f3bc2f4f08 Add pre-release number 2025-05-08 12:10:51 -07:00
b85ea1b420 Update README with changes 2025-05-08 08:53:03 -07:00
ed31a6724a fix race condition when resizing images - covers could be a duplicate of an existing image 2025-05-08 08:18:56 -07:00
955f5833cc resize _all_ of the covers, not just the root 2025-05-07 22:01:23 -07:00
aca297e7de sort jawns 2025-05-07 21:34:53 -07:00
933f179328 add image rendering progress bar 2025-05-07 21:30:12 -07:00
2ede813a73 hard link files in /site/ to save disk space 2025-05-07 21:07:22 -07:00
b38ad60e15 canonicalize album path 2025-05-07 20:58:51 -07:00
0d9d632195 better error message about missing config 2025-05-07 20:57:14 -07:00
396b434b39 quick by default 2025-05-07 20:51:54 -07:00
5c1e1feb17 fix tests 2025-05-07 20:48:50 -07:00
cf8394fb2a reduce image size 2025-05-07 20:48:21 -07:00
6476253d5f fix markdown description rendering 2025-05-07 17:13:53 -07:00
1613005115 add quick mode to skip image regen 2025-05-07 17:08:04 -07:00
90482446b1 get covers from children if no images in album 2025-05-07 16:52:33 -07:00
584ec41c7a Some fixes
- Render markdown descriptions
- Don't include the cover in the album's images, but make sure we
  generate a thumbnail for it
- Make extra sure we don't include slides dirs
2025-05-07 14:27:11 -07:00
890b46e45d use rayon for parallel image resizing 2025-05-07 08:37:26 -07:00
628e41afa3 clean up some TODOs 2025-05-07 08:24:02 -07:00
cc1bea1b81 leave a todo for myself 2025-05-06 21:22:44 -07:00
ac23e53614 clippy pass 2025-05-06 21:21:07 -07:00
2bacda9de5 fix image pages 2025-05-06 21:16:29 -07:00
141ea6dc5d fix breadcrumbs, include images in albums 2025-05-06 20:51:26 -07:00
a58878711b fix path to static on root path 2025-05-06 20:34:01 -07:00
53a6c34785 some fixes for album html rendering 2025-05-06 18:49:28 -07:00
9434eb835d basic image HTML rendering. Current tests pass! 2025-05-06 18:09:31 -07:00
bc33331dae clean up todos 2025-05-06 15:53:50 -07:00
5e54b84f04 album page rendering 2025-05-06 15:48:18 -07:00
f1c007845a fix relative path stuff 2025-05-05 18:26:21 -07:00
5d5c988ba4 add image generation 2025-05-04 20:39:21 -07:00
574a60ae8d add placeholder for checking image sizes in generated album 2025-05-04 16:06:19 -07:00
64f4d7e049 add album for tests 2025-05-04 16:06:05 -07:00
5f2490ff0c add a basic generate() test 2025-05-04 15:50:34 -07:00
e605ba84e5 bit of work toward generate() 2025-05-04 13:01:34 -07:00
36abf3ecc5 fix tests 2025-05-04 10:31:41 -07:00
4272a22a34 Start of reading album from a directory, including finding image files 2025-05-04 09:15:01 -07:00
0936ce2069 add AlbumDir with iterator to walk all images 2025-05-03 08:21:44 -07:00
be63b2fc77 help text for generate --quick 2025-05-01 07:52:58 -07:00
cf02721622 config loading 2025-04-29 17:07:49 -07:00
50d5fcd5de Remove unused clean CLI command 2025-04-29 15:53:05 -07:00
49d36f1d75 slices instead of making vecs 2025-04-29 15:49:58 -07:00
71659d2a91 Don't clobber existing files when creating skeleton 2025-04-29 08:04:34 -07:00
40af4997b0 add some tests 2025-04-28 08:42:33 -07:00
a52e4b4dde Remove consts 2025-04-27 10:20:27 -07:00
da8824a368 tell the user album was created 2025-04-27 09:49:05 -07:00
d097116c37 init command 2025-04-27 09:45:49 -07:00
5debb179ca relax deps 2025-04-27 08:34:34 -07:00
291350855f mirror the CLI 2025-04-26 20:32:13 -07:00
ceb872590f move python stuff out of the way 2025-04-26 19:49:29 -07:00
14 changed files with 274 additions and 621 deletions

2
.gitignore vendored
View file

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

115
Cargo.lock generated
View file

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

View file

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

Before

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,10 +1,9 @@
pub mod album_dir; pub mod album_dir;
mod image;
use crate::config::Config; use crate::config::Config;
use crate::generate::image::Image; use album_dir::{AlbumDir, Image};
use album_dir::AlbumDir;
use anyhow::{Context, anyhow}; use anyhow::{Context, anyhow};
use image::imageops::FilterType;
use indicatif::ProgressBar; use indicatif::ProgressBar;
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize; use serde::Serialize;
@ -17,7 +16,112 @@ use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
use tera::Tera; use tera::Tera;
const IMG_RESIZE_FILTER: ::image::imageops::FilterType = ::image::imageops::FilterType::Lanczos3; 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 /// Generate an album
/// ///
/// `root_path` is a path to the root directory of the album. `full` if true will regenerate /// `root_path` is a path to the root directory of the album. `full` if true will regenerate
@ -98,8 +202,7 @@ fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Res
fs::hard_link(&img.path, &full_size_path) fs::hard_link(&img.path, &full_size_path)
.with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?; .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); let thumb_path = output_path.join(&img.thumb_path);
log::info!( log::info!(
"Resizing {} -> {}", "Resizing {} -> {}",
@ -143,7 +246,7 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
.path .path
.join("_templates/*.html") .join("_templates/*.html")
.to_str() .to_str()
.ok_or(anyhow!("Album path {} is invalid", album.path.display()))?, .ok_or(anyhow!("Missing _templates dir in album dir"))?,
)?; )?;
println!("Generating HTML..."); println!("Generating HTML...");
@ -198,118 +301,15 @@ fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::generate; use super::generate;
use crate::skel::make_skeleton;
use mktemp::Temp;
use std::collections::{HashSet, VecDeque}; use std::collections::{HashSet, VecDeque};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::test_util::{init, make_test_album};
#[test] #[test]
/// Test that the generate function creates a rendered site as we expect it /// Test that the generate function creates a rendered site as we expect it
fn test_generate() { fn test_generate() {
@ -320,6 +320,27 @@ mod tests {
check_album(output_path).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 /// Does basic sanity checks on an output album
fn check_album(root_path: PathBuf) -> anyhow::Result<()> { fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
log::debug!("Checking album dir {}", root_path.display()); log::debug!("Checking album dir {}", root_path.display());

View file

@ -1,7 +1,7 @@
use crate::generate::image::Image;
use anyhow::anyhow; use anyhow::anyhow;
use image::ImageReader; use image::ImageReader;
use serde::Serialize; use serde::Serialize;
use std::ffi::OsString;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::slice::Iter; use std::slice::Iter;
@ -169,6 +169,93 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -227,4 +314,17 @@ mod tests {
]); ]);
assert_eq!(imgs, expected); 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")
);
}
} }

View file

@ -1,108 +0,0 @@
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,7 +1,3 @@
pub(crate) mod config; pub mod config;
pub mod generate; pub mod generate;
pub mod reorganize;
pub mod skel; pub mod skel;
#[cfg(test)]
pub(crate) mod test_util;

View file

@ -1,6 +1,5 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use photojawn::generate::generate; use photojawn::generate::generate;
use photojawn::reorganize::reorganize;
use photojawn::skel::make_skeleton; use photojawn::skel::make_skeleton;
use std::path::Path; use std::path::Path;
@ -19,9 +18,6 @@ fn main() -> anyhow::Result<()> {
let path = generate(&album_path.to_path_buf(), full)?; let path = generate(&album_path.to_path_buf(), full)?;
println!("Album site generated in {}", path.display()); println!("Album site generated in {}", path.display());
} }
Commands::Reorganize { path, dry_run } => {
reorganize(Path::new(&path), dry_run)?;
}
} }
Ok(()) Ok(())
@ -48,17 +44,4 @@ enum Commands {
#[arg(long)] #[arg(long)]
full: bool, 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,
},
} }

View file

@ -1,243 +0,0 @@
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());
}
}

View file

@ -1,25 +0,0 @@
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
}