Compare commits
54 commits
main
...
v0.2.0-pre
Author | SHA1 | Date | |
---|---|---|---|
1066367515 | |||
d2e077ef2d | |||
16b1d6f29d | |||
f00778d6cd | |||
fca9283f6f | |||
f225b4cd5b | |||
f3bc2f4f08 | |||
b85ea1b420 | |||
ed31a6724a | |||
955f5833cc | |||
aca297e7de | |||
933f179328 | |||
2ede813a73 | |||
b38ad60e15 | |||
0d9d632195 | |||
396b434b39 | |||
5c1e1feb17 | |||
cf8394fb2a | |||
6476253d5f | |||
1613005115 | |||
90482446b1 | |||
584ec41c7a | |||
890b46e45d | |||
628e41afa3 | |||
cc1bea1b81 | |||
ac23e53614 | |||
2bacda9de5 | |||
141ea6dc5d | |||
a58878711b | |||
53a6c34785 | |||
9434eb835d | |||
bc33331dae | |||
5e54b84f04 | |||
f1c007845a | |||
5d5c988ba4 | |||
574a60ae8d | |||
64f4d7e049 | |||
5f2490ff0c | |||
e605ba84e5 | |||
36abf3ecc5 | |||
4272a22a34 | |||
0936ce2069 | |||
be63b2fc77 | |||
cf02721622 | |||
50d5fcd5de | |||
49d36f1d75 | |||
71659d2a91 | |||
40af4997b0 | |||
a52e4b4dde | |||
da8824a368 | |||
d097116c37 | |||
5debb179ca | |||
291350855f | |||
ceb872590f |
2
.gitignore
vendored
|
@ -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
|
@ -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]]
|
||||||
|
|
32
Cargo.toml
|
@ -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"
|
||||||
|
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
245
src/generate.rs
|
@ -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());
|
||||||
|
|
|
@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
|
|
17
src/main.rs
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|