summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-03 19:28:51 +0300
committerPaul Buetow <paul@buetow.org>2026-06-03 19:28:51 +0300
commit53015df1627c035210f21845cacd401fae094bbf (patch)
tree290c3784fcbd503141eb7023ef374f7b11636c4f
parent77e65bdbc636e6c52353454d416cf03f42a74f6c (diff)
Add dry-run mode for photoalbum generation (wi0)
-rw-r--r--README.md27
-rwxr-xr-xbin/photoalbum96
-rwxr-xr-xsrc/photoalbum.sh96
-rwxr-xr-xtests/cli.sh141
-rwxr-xr-xtests/helpers.sh21
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 `<timestamp>` 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 - <<USAGE >&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-<timestamp>%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 - <<USAGE >&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-<timestamp>%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-<timestamp>.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-<timestamp>.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
@@ -1375,6 +1510,12 @@ main() {
'--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
test::run_case \
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