From 53015df1627c035210f21845cacd401fae094bbf Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 3 Jun 2026 19:28:51 +0300 Subject: Add dry-run mode for photoalbum generation (wi0) --- README.md | 27 +++++++---- bin/photoalbum | 96 +++++++++++++++++++++++++++++++++---- src/photoalbum.sh | 96 +++++++++++++++++++++++++++++++++---- tests/cli.sh | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.sh | 21 ++++++++ 5 files changed, 357 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3e62274..62fc09f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ modern `magick` command and falls back to `convert` when needed. ``` photoalbum --init photoalbum --generate [--config PATH] [OPTIONS] +photoalbum --dry-run [--config PATH] [OPTIONS] photoalbum --clean [--config PATH] [OPTIONS] photoalbum --version ``` @@ -28,12 +29,17 @@ photoalbum --version * `--init` creates `./photoalbum.conf` in the current working directory from the default config. It refuses to overwrite an existing file. * `--generate` builds the static album. +* `--dry-run` loads the config and overrides, validates the planned generation, + and prints the effective paths, image count, tarball plan, and generated file + plan without writing output or running ImageMagick or tar. * `--clean` removes the configured output directory. * `--version` prints the program version. -* `--config PATH` selects the config file for `--generate` or `--clean`. +* `--config PATH` selects the config file for `--generate`, `--dry-run`, or + `--clean`. -When `--config PATH` is not provided, `--generate` and `--clean` read -`./photoalbum.conf`. If the file is missing, run `photoalbum --init` first. +When `--config PATH` is not provided, `--generate`, `--dry-run`, and `--clean` +read `./photoalbum.conf`. If the file is missing, run `photoalbum --init` +first. The config file is a Bash file with assignments such as `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, @@ -50,6 +56,10 @@ processed as album images. Supported extensions are `jpg`, `jpeg`, `png`, `webp` and `gif`, matched case-insensitively. Other files, such as `.txt` or `.md` notes, are ignored with a warning so generation can continue. +`--dry-run` reports the same `INCOMING_DIR`, `DIST_DIR`, and `TEMPLATE_DIR` +values that generation would use after applying command-line overrides. Its +tarball filename uses `` as a placeholder so the output is stable. + Successful generation writes `photoalbum.json` into the output directory. This metadata records the generator version and timestamp, config source, template directory, supported source image and generated file counts, tarball status, and @@ -71,17 +81,18 @@ The following long options override config values: | `--tarball` | `TARBALL_INCLUDE=yes` | | `--no-tarball` | `TARBALL_INCLUDE=no` | -`--clean` accepts the same override options, but only `--dist` changes what it -removes. +`--dry-run` accepts the same override options as `--generate`. `--clean` accepts +the same override options, but only `--dist` changes what it removes. ## Example usage 1. Run `photoalbum --init`. 2. Edit `photoalbum.conf`. Set `INCOMING_DIR` to the directory containing the pictures and adjust `DIST_DIR`, `TITLE`, or template settings as needed. -3. Run `photoalbum --generate` to generate the album. -4. Distribute the `./dist` directory to a static web server. -5. Run `photoalbum --clean` to remove the generated output. +3. Run `photoalbum --dry-run` to inspect the planned generation. +4. Run `photoalbum --generate` to generate the album. +5. Distribute the `./dist` directory to a static web server. +6. Run `photoalbum --clean` to remove the generated output. ## HTML templates diff --git a/bin/photoalbum b/bin/photoalbum index caa5434..3e07fdb 100755 --- a/bin/photoalbum +++ b/bin/photoalbum @@ -11,6 +11,7 @@ usage() { cat - <&2 Usage: $0 --generate [--config PATH] [OPTIONS] + $0 --dry-run [--config PATH] [OPTIONS] $0 --clean [--config PATH] [OPTIONS] $0 --version $0 --init @@ -612,6 +613,22 @@ count_incoming_images() { incoming_image_files | wc -l } +tarball_name_plan() { + local base + + base=$(basename "$INCOMING_DIR") + printf '%s-%s\n' "$base" "${TARBALL_SUFFIX:-.tar}" +} + +generated_tarball_name() { + local base + local now + + base=$(basename "$INCOMING_DIR") + now=$(date +'%Y-%m-%d-%H%M%S') + printf '%s-%s%s\n' "$base" "$now" "${TARBALL_SUFFIX:-.tar}" +} + count_tree_files() { local -r dir="$1"; shift local -r name="$1"; shift @@ -673,16 +690,12 @@ write_generation_metadata() { } generate() { - local base local html_dir - local now local redirect_page local tarball_name='' if [ "${TARBALL_INCLUDE:-no}" = yes ]; then - base=$(basename "$INCOMING_DIR") - now=$(date +'%Y-%m-%d-%H%M%S') - tarball_name="${base}-${now}${TARBALL_SUFFIX:-.tar}" + tarball_name=$(generated_tarball_name) fi warn_unsupported_incoming_files @@ -706,6 +719,66 @@ generate() { write_generation_metadata "$tarball_name" } +dry_run() { + local -i image_count=0 + local -i html_index_count=1 + local -i page_count=0 + local -i redirect_count=0 + + image_count=$(count_incoming_images) + + if (( image_count > 0 )); then + page_count=$(( (image_count + MAXPREVIEWS - 1) / MAXPREVIEWS )) + redirect_count=$(( page_count * 2 )) + if (( image_count % MAXPREVIEWS != 0 )); then + (( ++redirect_count )) + fi + fi + + printf 'Dry run: no files will be written.\n' + printf 'Config source: %s\n' "${PHOTOALBUM_CONFIG_SOURCE:-}" + printf 'Incoming directory: %s\n' "$INCOMING_DIR" + printf 'Output directory: %s\n' "$DIST_DIR" + printf 'Template directory: %s\n' "$TEMPLATE_DIR" + printf 'Title: %s\n' "$TITLE" + printf 'Height: %s\n' "${HEIGHT:-}" + printf 'Thumb height: %s\n' "$THUMBHEIGHT" + printf 'Max previews per page: %s\n' "$MAXPREVIEWS" + printf 'Shuffle: %s\n' "${SHUFFLE:-no}" + printf 'Image count: %s\n' "$image_count" + printf 'Tarball setting: %s\n' "${TARBALL_INCLUDE:-no}" + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + printf 'Tarball name plan: %s\n' "$(tarball_name_plan)" + else + printf 'Tarball name plan: not planned\n' + fi + + printf 'Planned directories:\n' + printf ' %s\n' "$DIST_DIR" + printf ' %s/photos\n' "$DIST_DIR" + printf ' %s/thumbs\n' "$DIST_DIR" + printf ' %s/blurs\n' "$DIST_DIR" + printf ' %s/html\n' "$DIST_DIR" + + printf 'Planned generated files:\n' + printf ' %s/index.html\n' "$DIST_DIR" + printf ' %s/photoalbum.json\n' "$DIST_DIR" + printf ' %s/photos/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/thumbs/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/blurs/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/html/page-*.html (%s preview pages)\n' \ + "$DIST_DIR" "$page_count" + printf ' %s/html/[page]-[image].html (%s view pages)\n' \ + "$DIST_DIR" "$image_count" + printf ' %s/html/[redirect].html (%s navigation redirects)\n' \ + "$DIST_DIR" "$redirect_count" + printf ' %s/html/index.html (%s album index redirect)\n' \ + "$DIST_DIR" "$html_index_count" + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + printf ' %s/%s\n' "$DIST_DIR" "$(tarball_name_plan)" + fi +} + existing_parent_dir() { local -r path="$1"; shift local existing_parent @@ -1043,6 +1116,7 @@ validate_imagemagick() { } validate_generation_config() { + local -r require_imagemagick="${1:-yes}" local required_var local -a required_vars=( TITLE @@ -1072,7 +1146,9 @@ validate_generation_config() { validate_dist_dir validate_template_dir - validate_imagemagick + if [ "$require_imagemagick" = yes ]; then + validate_imagemagick + fi } main() { @@ -1162,7 +1238,7 @@ main() { cli_tarball_include='no' has_config_overrides='yes' ;; - --version|--init|--clean|--generate) + --version|--init|--clean|--generate|--dry-run) if [ -n "$action" ]; then usage exit 1 @@ -1194,7 +1270,7 @@ main() { init_config ;; - --clean|--generate) + --clean|--generate|--dry-run) rc_file="$(resolve_config_file "$config_file")" if [ ! -f "$rc_file" ]; then @@ -1217,6 +1293,10 @@ main() { validate_generation_config generate_staged ;; + --dry-run) + validate_generation_config no + dry_run + ;; esac ;; *) diff --git a/src/photoalbum.sh b/src/photoalbum.sh index ae85588..5179c5c 100755 --- a/src/photoalbum.sh +++ b/src/photoalbum.sh @@ -11,6 +11,7 @@ usage() { cat - <&2 Usage: $0 --generate [--config PATH] [OPTIONS] + $0 --dry-run [--config PATH] [OPTIONS] $0 --clean [--config PATH] [OPTIONS] $0 --version $0 --init @@ -612,6 +613,22 @@ count_incoming_images() { incoming_image_files | wc -l } +tarball_name_plan() { + local base + + base=$(basename "$INCOMING_DIR") + printf '%s-%s\n' "$base" "${TARBALL_SUFFIX:-.tar}" +} + +generated_tarball_name() { + local base + local now + + base=$(basename "$INCOMING_DIR") + now=$(date +'%Y-%m-%d-%H%M%S') + printf '%s-%s%s\n' "$base" "$now" "${TARBALL_SUFFIX:-.tar}" +} + count_tree_files() { local -r dir="$1"; shift local -r name="$1"; shift @@ -673,16 +690,12 @@ write_generation_metadata() { } generate() { - local base local html_dir - local now local redirect_page local tarball_name='' if [ "${TARBALL_INCLUDE:-no}" = yes ]; then - base=$(basename "$INCOMING_DIR") - now=$(date +'%Y-%m-%d-%H%M%S') - tarball_name="${base}-${now}${TARBALL_SUFFIX:-.tar}" + tarball_name=$(generated_tarball_name) fi warn_unsupported_incoming_files @@ -706,6 +719,66 @@ generate() { write_generation_metadata "$tarball_name" } +dry_run() { + local -i image_count=0 + local -i html_index_count=1 + local -i page_count=0 + local -i redirect_count=0 + + image_count=$(count_incoming_images) + + if (( image_count > 0 )); then + page_count=$(( (image_count + MAXPREVIEWS - 1) / MAXPREVIEWS )) + redirect_count=$(( page_count * 2 )) + if (( image_count % MAXPREVIEWS != 0 )); then + (( ++redirect_count )) + fi + fi + + printf 'Dry run: no files will be written.\n' + printf 'Config source: %s\n' "${PHOTOALBUM_CONFIG_SOURCE:-}" + printf 'Incoming directory: %s\n' "$INCOMING_DIR" + printf 'Output directory: %s\n' "$DIST_DIR" + printf 'Template directory: %s\n' "$TEMPLATE_DIR" + printf 'Title: %s\n' "$TITLE" + printf 'Height: %s\n' "${HEIGHT:-}" + printf 'Thumb height: %s\n' "$THUMBHEIGHT" + printf 'Max previews per page: %s\n' "$MAXPREVIEWS" + printf 'Shuffle: %s\n' "${SHUFFLE:-no}" + printf 'Image count: %s\n' "$image_count" + printf 'Tarball setting: %s\n' "${TARBALL_INCLUDE:-no}" + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + printf 'Tarball name plan: %s\n' "$(tarball_name_plan)" + else + printf 'Tarball name plan: not planned\n' + fi + + printf 'Planned directories:\n' + printf ' %s\n' "$DIST_DIR" + printf ' %s/photos\n' "$DIST_DIR" + printf ' %s/thumbs\n' "$DIST_DIR" + printf ' %s/blurs\n' "$DIST_DIR" + printf ' %s/html\n' "$DIST_DIR" + + printf 'Planned generated files:\n' + printf ' %s/index.html\n' "$DIST_DIR" + printf ' %s/photoalbum.json\n' "$DIST_DIR" + printf ' %s/photos/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/thumbs/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/blurs/* (%s image files)\n' "$DIST_DIR" "$image_count" + printf ' %s/html/page-*.html (%s preview pages)\n' \ + "$DIST_DIR" "$page_count" + printf ' %s/html/[page]-[image].html (%s view pages)\n' \ + "$DIST_DIR" "$image_count" + printf ' %s/html/[redirect].html (%s navigation redirects)\n' \ + "$DIST_DIR" "$redirect_count" + printf ' %s/html/index.html (%s album index redirect)\n' \ + "$DIST_DIR" "$html_index_count" + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + printf ' %s/%s\n' "$DIST_DIR" "$(tarball_name_plan)" + fi +} + existing_parent_dir() { local -r path="$1"; shift local existing_parent @@ -1043,6 +1116,7 @@ validate_imagemagick() { } validate_generation_config() { + local -r require_imagemagick="${1:-yes}" local required_var local -a required_vars=( TITLE @@ -1072,7 +1146,9 @@ validate_generation_config() { validate_dist_dir validate_template_dir - validate_imagemagick + if [ "$require_imagemagick" = yes ]; then + validate_imagemagick + fi } main() { @@ -1162,7 +1238,7 @@ main() { cli_tarball_include='no' has_config_overrides='yes' ;; - --version|--init|--clean|--generate) + --version|--init|--clean|--generate|--dry-run) if [ -n "$action" ]; then usage exit 1 @@ -1194,7 +1270,7 @@ main() { init_config ;; - --clean|--generate) + --clean|--generate|--dry-run) rc_file="$(resolve_config_file "$config_file")" if [ ! -f "$rc_file" ]; then @@ -1217,6 +1293,10 @@ main() { validate_generation_config generate_staged ;; + --dry-run) + validate_generation_config no + dry_run + ;; esac ;; *) diff --git a/tests/cli.sh b/tests/cli.sh index 1ed52b1..3a6be1a 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -502,6 +502,141 @@ test_generate_cli_no_tarball_overrides_config() { test::teardown } +test_dry_run_reports_cli_overrides_without_writes() { + local config_file + local dist_dir + local fake_bin + local forbidden_log + local output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/custom.conf" + dist_dir="$TEST_TMPDIR/dry-dist" + forbidden_log="$TEST_TMPDIR/forbidden-tools.log" + + test::install_fake_imagemagick "$fake_bin" + PATH="$fake_bin:$PATH" \ + test::generate_fixture_images "$TEST_TMPDIR/incoming" + test::install_failing_generation_tools "$fake_bin" + + { + printf 'TITLE=%q\n' 'Config dry title' + printf 'THUMBHEIGHT=10\n' + printf 'HEIGHT=20\n' + printf 'MAXPREVIEWS=40\n' + printf 'SHUFFLE=no\n' + printf 'INCOMING_DIR=%q/config-incoming\n' "$TEST_TMPDIR" + printf 'DIST_DIR=%q/config-dist\n' "$TEST_TMPDIR" + printf 'TEMPLATE_DIR=%q/config-template\n' "$TEST_TMPDIR" + printf 'TARBALL_INCLUDE=no\n' + } > "$config_file" + + output=$( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" \ + TEST_FORBIDDEN_TOOL_LOG="$forbidden_log" \ + "$TEST_PHOTOALBUM" \ + --dry-run \ + --config "$config_file" \ + --incoming "$TEST_TMPDIR/incoming" \ + --dist "$dist_dir" \ + --template "$TEST_REPO_ROOT/share/templates/default" \ + --title 'CLI dry title' \ + --height 456 \ + --thumbheight 45 \ + --maxpreviews 2 \ + --shuffle \ + --tarball + ) + + test::assert_contains 'Dry run: no files will be written.' "$output" + test::assert_contains "Config source: $config_file" "$output" + test::assert_contains "Incoming directory: $TEST_TMPDIR/incoming" "$output" + test::assert_contains "Output directory: $dist_dir" "$output" + test::assert_contains \ + "Template directory: $TEST_REPO_ROOT/share/templates/default" \ + "$output" + test::assert_contains 'Title: CLI dry title' "$output" + test::assert_contains 'Height: 456' "$output" + test::assert_contains 'Thumb height: 45' "$output" + test::assert_contains 'Max previews per page: 2' "$output" + test::assert_contains 'Shuffle: yes' "$output" + test::assert_contains 'Image count: 6' "$output" + test::assert_contains 'Tarball setting: yes' "$output" + test::assert_contains 'Tarball name plan: incoming-.tar' \ + "$output" + test::assert_contains 'Planned directories:' "$output" + test::assert_contains " $dist_dir/photos" "$output" + test::assert_contains 'Planned generated files:' "$output" + test::assert_contains " $dist_dir/index.html" "$output" + test::assert_contains " $dist_dir/photoalbum.json" "$output" + test::assert_contains " $dist_dir/photos/* (6 image files)" "$output" + test::assert_contains " $dist_dir/thumbs/* (6 image files)" "$output" + test::assert_contains " $dist_dir/blurs/* (6 image files)" "$output" + test::assert_contains " $dist_dir/html/page-*.html (3 preview pages)" \ + "$output" + test::assert_contains \ + " $dist_dir/html/[page]-[image].html (6 view pages)" \ + "$output" + test::assert_contains \ + " $dist_dir/html/[redirect].html (6 navigation redirects)" \ + "$output" + test::assert_contains \ + " $dist_dir/html/index.html (1 album index redirect)" \ + "$output" + test::assert_contains " $dist_dir/incoming-.tar" "$output" + test::assert_not_contains 'Processing ' "$output" + test::assert_not_contains 'Creating tarball ' "$output" + test::assert_path_absent "$dist_dir" + test::assert_path_absent "$TEST_TMPDIR/config-dist" + test::assert_path_absent "$forbidden_log" + test::assert_no_staging_dirs "$TEST_TMPDIR" + test::teardown +} + +test_dry_run_rejects_invalid_config_and_input() { + local config_file + local dist_dir + local incoming_dir + local output + + test::setup + config_file="$TEST_TMPDIR/photoalbum.conf" + incoming_dir="$TEST_TMPDIR/incoming" + dist_dir="$TEST_TMPDIR/dist" + mkdir -p "$incoming_dir" + test::write_preflight_config \ + "$config_file" "$incoming_dir" "$dist_dir" \ + "$TEST_REPO_ROOT/share/templates/default" + printf 'MAXPREVIEWS=not-a-number\n' >> "$config_file" + + output=$( + cd "$TEST_TMPDIR" + test::capture_failure_output \ + "$TEST_PHOTOALBUM" --dry-run --config "$config_file" + ) + + test::assert_contains 'ERROR: MAXPREVIEWS must be a positive integer' \ + "$output" + test::assert_path_absent "$dist_dir" + + test::write_preflight_config \ + "$config_file" "$TEST_TMPDIR/missing" "$dist_dir" \ + "$TEST_REPO_ROOT/share/templates/default" + output=$( + cd "$TEST_TMPDIR" + test::capture_failure_output \ + "$TEST_PHOTOALBUM" --dry-run --config "$config_file" + ) + + test::assert_contains \ + "ERROR: You have to create $TEST_TMPDIR/missing first" \ + "$output" + test::assert_path_absent "$dist_dir" + test::teardown +} + test_generate_ignores_unsupported_incoming_files_with_warning() { local config_file local fake_bin @@ -1374,6 +1509,12 @@ main() { test::run_case \ '--generate --no-tarball overrides config' \ test_generate_cli_no_tarball_overrides_config + test::run_case \ + '--dry-run reports CLI overrides without writes' \ + test_dry_run_reports_cli_overrides_without_writes + test::run_case \ + '--dry-run rejects invalid config and input' \ + test_dry_run_rejects_invalid_config_and_input test::run_case \ '--generate ignores unsupported incoming files with warning' \ test_generate_ignores_unsupported_incoming_files_with_warning diff --git a/tests/helpers.sh b/tests/helpers.sh index f0ef852..9fb932a 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -220,6 +220,27 @@ MAGICK cp "$bin_dir/magick" "$bin_dir/convert" } +test::install_failing_generation_tools() { + local -r bin_dir="$1"; shift + local name + + mkdir -p "$bin_dir" + + for name in magick convert tar; do + cat > "$bin_dir/$name" <<'TOOL' +#!/usr/bin/env bash +set -euo pipefail + +if [ -n "${TEST_FORBIDDEN_TOOL_LOG:-}" ]; then + printf 'called %s\n' "$(basename "$0")" >> "$TEST_FORBIDDEN_TOOL_LOG" +fi +echo "unexpected generation tool invocation: $(basename "$0")" >&2 +exit 97 +TOOL + chmod 0755 "$bin_dir/$name" + done +} + test::install_mv_spy() { local -r bin_dir="$1"; shift local real_mv -- cgit v1.2.3