From cbf67b99530aaddf52cb85c8edc80b3f13279ce3 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 11 Jun 2026 09:55:39 +0300 Subject: Split photoalbum source into lib modules --- src/lib/action.source.sh | 117 ++ src/lib/album.source.sh | 900 ++++++++++++++ src/lib/bootstrap.source.sh | 173 +++ src/lib/config.source.sh | 679 ++++++++++ src/lib/image.source.sh | 291 +++++ src/lib/system.source.sh | 172 +++ src/lib/template.source.sh | 502 ++++++++ src/photoalbum.sh | 2859 +------------------------------------------ 8 files changed, 2853 insertions(+), 2840 deletions(-) create mode 100644 src/lib/action.source.sh create mode 100644 src/lib/album.source.sh create mode 100644 src/lib/bootstrap.source.sh create mode 100644 src/lib/config.source.sh create mode 100644 src/lib/image.source.sh create mode 100644 src/lib/system.source.sh create mode 100644 src/lib/template.source.sh (limited to 'src') diff --git a/src/lib/action.source.sh b/src/lib/action.source.sh new file mode 100644 index 0000000..01dc245 --- /dev/null +++ b/src/lib/action.source.sh @@ -0,0 +1,117 @@ +run_simple_action() { + case "$action" in + --version) + if [[ -n "$config_file" || "$has_config_overrides" = 'yes' \ + || "$PHOTOALBUM_FORCE_GENERATE" = yes ]]; then + usage + exit 1 + fi + + printf 'This is Photoalbum Version %s\n' "$VERSION" + ;; + --init) + if [[ -n "$config_file" || "$has_config_overrides" = 'yes' \ + || "$PHOTOALBUM_FORCE_GENERATE" = yes ]]; then + usage + exit 1 + fi + + init_config + ;; + esac +} + +load_configured_action() { + local -r rc_file="$1"; shift + + if [ ! -f "$rc_file" ]; then + missing_config "$rc_file" + fi + + # shellcheck source=/dev/null + source "$rc_file" + apply_config_defaults + apply_template_dir_default + apply_cli_overrides + PHOTOALBUM_CONFIG_SOURCE="$rc_file" + export PHOTOALBUM_CONFIG_SOURCE +} + +log_configured_action() { + local -r rc_file="$1"; shift + + if [ "$action" = --print-config ]; then + return + fi + + log_verbose "Selected config file: $rc_file" + log_verbose "Effective incoming directory: ${INCOMING_DIR:-}" + log_verbose "Effective output directory: ${DIST_DIR:-}" + log_verbose "Effective template directory: ${TEMPLATE_DIR:-}" + log_verbose "Effective image jobs: ${IMAGE_JOBS:-3}" + log_verbose "Effective ImageMagick timeout: ${IMAGEMAGICK_TIMEOUT:-60}s" + log_verbose "Effective tar timeout: ${TAR_TIMEOUT:-120}s" + log_verbose "Effective splash page setting: ${SPLASH_PAGE:-yes}" + log_verbose "Effective tarball setting: ${TARBALL_INCLUDE:-no}" + log_verbose "Effective sync delete setting: ${SYNC_DELETE:-yes}" + log_verbose "Effective force generation setting: $PHOTOALBUM_FORCE_GENERATE" +} + +run_configured_action() { + local rc_file + + if [[ "$PHOTOALBUM_FORCE_GENERATE" = yes && "$action" != --generate ]]; then + usage + exit 1 + fi + + rc_file="$(resolve_config_file "$config_file")" + load_configured_action "$rc_file" + log_configured_action "$rc_file" + + case "$action" in + --clean) + if [ -d "$DIST_DIR" ]; then + log_info "Cleaning $DIST_DIR" + rm -rf "$DIST_DIR" + else + log_verbose "Output directory does not exist: $DIST_DIR" + fi + ;; + --generate) + validate_generation_config + generate_staged + ;; + --refresh-splash) + validate_refresh_splash_config + refresh_splash + ;; + --sync) + validate_sync_config + sync_dist + ;; + --dry-run) + validate_generation_config no + dry_run + ;; + --print-config) + validate_print_config + print_config + ;; + esac +} + +run_action() { + case "$action" in + --version|--init) + run_simple_action + ;; + --clean|--generate|--refresh-splash|--sync|--dry-run|--print-config) + run_configured_action + ;; + *) + usage + exit 1 + ;; + esac +} diff --git a/src/lib/album.source.sh b/src/lib/album.source.sh new file mode 100644 index 0000000..886bd81 --- /dev/null +++ b/src/lib/album.source.sh @@ -0,0 +1,900 @@ +album_photo_files() { + local -r photos_dir="$1"; shift + + find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + | maybe_shuffle +} + +start_preview_page() { + local -r photos_dir="$1"; shift + local -r html_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_name="$1"; shift + local -r header_bar="$1"; shift + local background_image + + background_image=$(randomphoto "$photos_dir" "$page_name") + template header "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir "$blurs_dir" \ + background_image "$background_image" \ + show_header_bar "$header_bar" +} + +finish_preview_page() { + local -r page_name="$1"; shift + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + + template footer "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name "$tarball_name" +} + +finish_preview_page_with_next() { + local -r page_name="$1"; shift + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + local -r next_page="$1"; shift + local -r prev_page="$1"; shift + + template next "$page_name.html" \ + html_dir "$html_dir" \ + next "$next_page" \ + prev "$prev_page" + finish_preview_page "$page_name" "$html_dir" "$backhref" "$tarball_name" +} + +render_previous_page_link() { + local -r page_name="$1"; shift + local -r html_dir="$1"; shift + local -r prev_page="$1"; shift + + template prev "$page_name.html" \ + html_dir "$html_dir" \ + prev "$prev_page" +} + +render_preview_thumbnail() { + local -r page_name="$1"; shift + local -r html_dir="$1"; shift + local -r thumbs_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo_file="$1"; shift + local animation_class + + animation_class=$(random_animation_css_class slow "$photo_file") + template preview "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + thumbs_dir "$thumbs_dir" \ + page_num "$page_num" \ + preview_num "$preview_num" \ + photo "$photo_file" \ + animation_class "$animation_class" +} + +render_view_page() { + local -r html_dir="$1"; shift + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo_file="$1"; shift + local animation_class + + template header "$page_num-$preview_num.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir "$blurs_dir" \ + background_image "$photo_file" \ + show_header_bar 'no' + + animation_class=$(random_animation_css_class fast "$photo_file") + template view "$page_num-$preview_num.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + photos_dir "$photos_dir" \ + page_num "$page_num" \ + preview_num "$preview_num" \ + photo "$photo_file" \ + animation_class "$animation_class" + template footer "$page_num-$preview_num.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name "$tarball_name" +} + +photo_cache_signature() { + local -r photo="$1"; shift + local -r photo_path="$1"; shift + local stat_output + + stat_output=$(stat -c '%s:%Y' "$photo_path") + printf '%s:%s\n' "$photo" "$stat_output" +} + +print_cached_photo_identify_output() { + local -r cache_file="$1"; shift + local line + local skipped_signature=no + + while IFS= read -r line || [ -n "$line" ]; do + if [ "$skipped_signature" = no ]; then + skipped_signature=yes + continue + fi + printf '%s\n' "$line" + done < "$cache_file" +} + +cached_photo_identify_output() { + local -r photo="$1"; shift + local -r photo_path="$1"; shift + local cache_dir + local cache_file + local cached_signature='' + local current_signature + + cache_dir="$DIST_DIR/.photoalbum-cache/exif" + cache_file="$cache_dir/$photo.txt" + current_signature=$(photo_cache_signature "$photo" "$photo_path") + + if [ -f "$cache_file" ]; then + IFS= read -r cached_signature < "$cache_file" || true + if [ "$cached_signature" = "$current_signature" ]; then + print_cached_photo_identify_output "$cache_file" + return + fi + fi + + mkdir -p "$cache_dir" + printf '%s\n' "$current_signature" > "$cache_file" + imagemagick_identify -verbose "$photo_path" >> "$cache_file" 2>/dev/null \ + || true + print_cached_photo_identify_output "$cache_file" +} + +photo_exif_details_html() { + local -r photo="$1"; shift + local -r photo_path="$1"; shift + local key + local key_html + local line + local value + local value_html + local -i exif_count=0 + + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*exif:([^:]+):[[:space:]]*(.*)$ ]]; then + key="exif:${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + key_html=$(_html_escape "$key") + value_html=$(_html_escape "$value") + + if (( exif_count == 0 )); then + printf '\n' + printf '\n' + fi + + printf '\n' \ + "$key_html" \ + "$value_html" + (( ++exif_count )) + fi + done < <(cached_photo_identify_output "$photo" "$photo_path") + + if (( exif_count == 0 )); then + printf '

No EXIF details available.

\n' + return + fi + + printf '\n' + printf '
%s%s
\n' +} + +render_details_page() { + local -r html_dir="$1"; shift + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo_file="$1"; shift + local animation_class + local exif_details_html + + template header "$page_num-$preview_num-details.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir "$blurs_dir" \ + background_image "$photo_file" \ + show_header_bar 'no' + + animation_class=$(random_animation_css_class fast "$photo_file") + exif_details_html=$( + photo_exif_details_html "$photo_file" "$INCOMING_DIR/$photo_file" + ) + template details "$page_num-$preview_num-details.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + photos_dir "$photos_dir" \ + page_num "$page_num" \ + preview_num "$preview_num" \ + photo "$photo_file" \ + animation_class "$animation_class" \ + exif_details "$exif_details_html" + template footer "$page_num-$preview_num-details.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name "$tarball_name" +} + +create_photo_derivatives() { + local -r photos_dir="$1"; shift + local -r thumbs_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r photo="$1"; shift + local dirname + local height + + if [[ -f "$DIST_DIR/$thumbs_dir/$photo" \ + && -f "$DIST_DIR/$blurs_dir/$photo" ]]; then + log_verbose "Skipped existing thumb and blur" \ + "$(_display_path "$DIST_DIR/$thumbs_dir/$photo") and" \ + "$(_display_path "$DIST_DIR/$blurs_dir/$photo")" + return + fi + + dirname="$DIST_DIR/$thumbs_dir" + mkdir -p "$dirname" + log_info "Creating thumb $(_display_path "$DIST_DIR/$thumbs_dir/$photo")" + # Double the height, as CSS scales images based on boxing too. + height=$(( THUMBHEIGHT * 2 )) + imagemagick \ + "$DIST_DIR/$photos_dir/$photo" \ + -geometry "x$height" \ + "$DIST_DIR/$thumbs_dir/$photo" + + dirname="$DIST_DIR/$blurs_dir" + mkdir -p "$dirname" + log_info "Creating blur $(_display_path "$DIST_DIR/$blurs_dir/$photo")" + imagemagick \ + "$DIST_DIR/$thumbs_dir/$photo" \ + -flip \ + -blur 0x8 \ + "$DIST_DIR/$blurs_dir/$photo" +} + +create_all_photo_derivatives() { + local -r photos_dir="$1"; shift + local -r thumbs_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -i failed=0 + local -a image_job_pids=() + local photo + + while IFS= read -r photo; do + wait_for_image_job_slot image_job_pids failed + create_photo_derivatives "$photos_dir" "$thumbs_dir" "$blurs_dir" \ + "$photo" & + image_job_pids+=("$!") + done < <( + find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + | sort + ) + + wait_for_image_jobs image_job_pids failed + if (( failed != 0 )); then + return 1 + fi +} + +render_album_page_thumbnail() { + local -r page_name="$1"; shift + local -r html_dir="$1"; shift + local -r thumbs_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo="$1"; shift + + render_preview_thumbnail \ + "$page_name" \ + "$html_dir" \ + "$thumbs_dir" \ + "$backhref" \ + "$page_num" \ + "$preview_num" \ + "$photo" +} + +render_photo_view_and_details() { + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo="$1"; shift + + render_view_page \ + "$html_dir" \ + "$photos_dir" \ + "$blurs_dir" \ + "$backhref" \ + "$tarball_name" \ + "$page_num" \ + "$preview_num" \ + "$photo" + render_details_page \ + "$html_dir" \ + "$photos_dir" \ + "$blurs_dir" \ + "$backhref" \ + "$tarball_name" \ + "$page_num" \ + "$preview_num" \ + "$photo" +} + +record_rendered_view_page() { + # shellcheck disable=SC2178 + local -n view_pages_ref="$1"; shift + # shellcheck disable=SC2178 + local -n last_views_ref="$1"; shift + local -r page="$1"; shift + local -r preview="$1"; shift + + if [ -z "${last_views_ref[$page]+set}" ]; then + view_pages_ref+=("$page") + fi + last_views_ref["$page"]="$preview" +} + +render_view_redirects() { + local -r html_dir="$1"; shift + # shellcheck disable=SC2178 + local -n view_pages_ref="$1"; shift + # shellcheck disable=SC2178 + local -n last_views_ref="$1"; shift + local lastview + local max_page + local nextredirect + local page + local prevredirect + + if (( ${#view_pages_ref[@]} == 0 )); then + return + fi + + max_page=${view_pages_ref[$(( ${#view_pages_ref[@]} - 1 ))]} + + for page in "${view_pages_ref[@]}"; do + lastview=${last_views_ref[$page]} + prevredirect="${page}-0" + nextredirect="${page}-$(( lastview + 1 ))" + + template redirect "$prevredirect.html" \ + html_dir "$html_dir" \ + redirect_page "$(( page - 1 ))-${MAXPREVIEWS}" + + if (( page == max_page )); then + template redirect "0-$MAXPREVIEWS.html" \ + html_dir "$html_dir" \ + redirect_page "${page}-$lastview" + template redirect "$nextredirect.html" \ + html_dir "$html_dir" \ + redirect_page '1-1' + else + template redirect "$nextredirect.html" \ + html_dir "$html_dir" \ + redirect_page "$(( page + 1 ))-1" + fi + done +} + +render_album_index_redirect() { + local -r html_dir="$1"; shift + + template 'redirect' 'index.html' \ + html_dir "$html_dir" \ + redirect_page 'page-1' +} + +render_album_splash_page() { + local -r photos_dir="$1"; shift + local -r html_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local html='index.html' + local photo + + if (( $# > 0 )); then + html="$1" + shift + fi + + photo=$(random_splash_photo "$photos_dir" "$blurs_dir") + template 'splash' "$html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir "$blurs_dir" \ + background_image "$photo" \ + photos_dir "$photos_dir" \ + photo "$photo" \ + enter_page 'page-1' +} + +render_album_index() { + local -r photos_dir="$1"; shift + local -r html_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + + if [ "${SPLASH_PAGE:-yes}" = yes ]; then + render_album_splash_page "$photos_dir" "$html_dir" "$blurs_dir" \ + "$backhref" + else + render_album_index_redirect "$html_dir" + fi +} + +album_page_name() { + local -r page_num="$1"; shift + + printf 'page-%s\n' "$page_num" +} + +advance_album_preview_page() { + local -r photos_dir="$1"; shift + local -r html_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + # shellcheck disable=SC2178 + local -n page_name_ref="$1"; shift + # shellcheck disable=SC2178 + local -n prev_page_ref="$1"; shift + # shellcheck disable=SC2178 + local -n page_num_ref="$1"; shift + # shellcheck disable=SC2178 + local -n preview_num_ref="$1"; shift + local next_page_name + + next_page_name=$(album_page_name "$(( page_num_ref + 1 ))") + finish_preview_page_with_next \ + "$page_name_ref" \ + "$html_dir" \ + "$backhref" \ + "$tarball_name" \ + "$next_page_name" \ + "${prev_page_ref:-}" + + prev_page_ref="$page_name_ref" + (( ++page_num_ref )) + # shellcheck disable=SC2034 + preview_num_ref=1 + page_name_ref=$(album_page_name "$page_num_ref") + + start_preview_page \ + "$photos_dir" \ + "$html_dir" \ + "$blurs_dir" \ + "$backhref" \ + "$page_name_ref" \ + 'no' + render_previous_page_link "$page_name_ref" "$html_dir" "$prev_page_ref" +} + +queue_album_view_render_job() { + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + local -r page_num="$1"; shift + local -r preview_num="$1"; shift + local -r photo="$1"; shift + # shellcheck disable=SC2178 + local -n render_job_pids_ref="$1"; shift + # shellcheck disable=SC2178 + local -n render_failed_ref="$1"; shift + + wait_for_image_job_slot render_job_pids_ref render_failed_ref + render_photo_view_and_details \ + "$photos_dir" \ + "$blurs_dir" \ + "$html_dir" \ + "$backhref" \ + "$tarball_name" \ + "$page_num" \ + "$preview_num" \ + "$photo" & + render_job_pids_ref+=("$!") +} + +wait_for_album_view_render_jobs() { + # shellcheck disable=SC2178 + local -n render_job_pids_ref="$1"; shift + # shellcheck disable=SC2178 + local -n render_failed_ref="$1"; shift + + wait_for_image_jobs render_job_pids_ref render_failed_ref + if (( render_failed_ref != 0 )); then + return 1 + fi +} + +render_album_pages() { + local -r photos_dir="$1"; shift + local -r html_dir="$1"; shift + local -r thumbs_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -r backhref="$1"; shift + local -r tarball_name="$1"; shift + + local name + local photo + # Passed by name to advance_album_preview_page. + # shellcheck disable=SC2034 + local prev + local -i i=0 + local -i num=1 + # Passed by name to queue_album_view_render_job. + # shellcheck disable=SC2034 + local -i render_failed=0 + # Passed by name to queue_album_view_render_job. + # shellcheck disable=SC2034 + local -a render_job_pids=() + # Passed by name to record_rendered_view_page and render_view_redirects. + # shellcheck disable=SC2034 + local -A rendered_last_views=() + # Passed by name to record_rendered_view_page and render_view_redirects. + # shellcheck disable=SC2034 + local -a rendered_view_pages=() + + name=$(album_page_name "$num") + + start_preview_page \ + "$photos_dir" "$html_dir" "$blurs_dir" "$backhref" "$name" 'yes' + + while IFS= read -r photo; do + (( ++i )) + + if (( i > MAXPREVIEWS )); then + advance_album_preview_page \ + "$photos_dir" \ + "$html_dir" \ + "$blurs_dir" \ + "$backhref" \ + "$tarball_name" \ + name \ + prev \ + num \ + i + fi + + render_album_page_thumbnail \ + "$name" \ + "$html_dir" \ + "$thumbs_dir" \ + "$backhref" \ + "$num" \ + "$i" \ + "$photo" + queue_album_view_render_job \ + "$photos_dir" \ + "$blurs_dir" \ + "$html_dir" \ + "$backhref" \ + "$tarball_name" \ + "$num" \ + "$i" \ + "$photo" \ + render_job_pids \ + render_failed + record_rendered_view_page rendered_view_pages rendered_last_views \ + "$num" "$i" + done < <(album_photo_files "$photos_dir") + + finish_preview_page "$name" "$html_dir" "$backhref" "$tarball_name" + if ! wait_for_album_view_render_jobs render_job_pids render_failed; then + return 1 + fi + render_view_redirects "$html_dir" rendered_view_pages rendered_last_views + render_album_index "$photos_dir" "$html_dir" "$blurs_dir" "$backhref" +} + +splash_photo_files() { + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local photo + + while IFS= read -r photo; do + if [ -f "$DIST_DIR/$blurs_dir/$photo" ]; then + printf '%s\n' "$photo" + fi + done < <( + find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + | sort + ) +} + +random_splash_photo() { + local -r photos_dir="$1"; shift + local -r blurs_dir="$1"; shift + local -i index + local photo + local -a photos=() + + while IFS= read -r photo; do + photos+=("$photo") + done < <(splash_photo_files "$photos_dir" "$blurs_dir") + + if (( ${#photos[@]} == 0 )); then + printf 'ERROR: No splash photos found in %s with matching blurs in %s\n' \ + "$(_display_path "$DIST_DIR/$photos_dir")" \ + "$(_display_path "$DIST_DIR/$blurs_dir")" >&2 + return 1 + fi + + index=$(random_index "photo:$photos_dir:splash" "${#photos[@]}") + printf '%s\n' "${photos[index]}" +} + +randomphoto() { + local -r photos_dir="$1"; shift + local -r context="${1:-$photos_dir}" + local -i index + local photo + local -a photos=() + + while IFS= read -r photo; do + photos+=("$photo") + done < <( + find "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + | sort + ) + + if (( ${#photos[@]} == 0 )); then + printf 'ERROR: No photos found in %s\n' \ + "$(_display_path "$DIST_DIR/$photos_dir")" >&2 + return 1 + fi + + index=$(random_index "photo:$photos_dir:$context" "${#photos[@]}") + printf '%s\n' "${photos[index]}" +} + +count_files() { + local -r dir="$1"; shift + local -r name="${1:-}"; shift || true + + if [ ! -d "$dir" ]; then + printf '0\n' + return + fi + + if [ -n "$name" ]; then + find "$dir" -maxdepth 1 -type f -name "$name" | wc -l + else + find "$dir" -maxdepth 1 -type f | wc -l + fi +} + +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 timestamp + + base=$(basename "$INCOMING_DIR") + timestamp=$(current_timestamp_slug) + printf '%s-%s%s\n' "$base" "$timestamp" "${TARBALL_SUFFIX:-.tar}" +} + +count_tree_files() { + local -r dir="$1"; shift + local -r name="$1"; shift + + if [ ! -d "$dir" ]; then + printf '0\n' + return + fi + + find "$dir" -type f -name "$name" | wc -l +} + +write_generation_metadata() { + local -r tarball_file="$1"; shift + local -r generated_at=$(current_timestamp_iso) + local -r config_source="${PHOTOALBUM_CONFIG_SOURCE:-}" + local -r template_name=$(basename "$TEMPLATE_DIR") + local -r source_image_count=$(count_incoming_images) + local -r generated_photo_count=$(count_files "$DIST_DIR/photos") + local -r generated_thumb_count=$(count_files "$DIST_DIR/thumbs") + local -r generated_html_count=$(count_tree_files "$DIST_DIR" '*.html') + + { + printf '{\n' + printf ' "generator": {\n' + printf ' "name": "photoalbum",\n' + printf ' "version": %s\n' "$(_json_string "$VERSION")" + printf ' },\n' + printf ' "generated_at": %s,\n' "$(_json_string "$generated_at")" + printf ' "config_source": %s,\n' "$(_json_string "$config_source")" + printf ' "template": {\n' + printf ' "name": %s,\n' "$(_json_string "$template_name")" + printf ' "directory": %s\n' "$(_json_string "$TEMPLATE_DIR")" + printf ' },\n' + printf ' "source": {\n' + printf ' "incoming_dir": %s,\n' "$(_json_string "$INCOMING_DIR")" + printf ' "image_count": %s\n' "$source_image_count" + printf ' },\n' + printf ' "generated": {\n' + printf ' "photo_count": %s,\n' "$generated_photo_count" + printf ' "thumb_count": %s,\n' "$generated_thumb_count" + printf ' "html_count": %s\n' "$generated_html_count" + printf ' },\n' + printf ' "tarball": {\n' + printf ' "included": %s,\n' "$(_json_bool "${TARBALL_INCLUDE:-no}")" + printf ' "file": %s\n' "$(_json_string "$tarball_file")" + printf ' },\n' + printf ' "settings": {\n' + printf ' "title": %s,\n' "$(_json_string "${TITLE:-}")" + printf ' "height": %s,\n' "$(_json_string "${HEIGHT:-}")" + printf ' "thumbheight": %s,\n' "$(_json_string "${THUMBHEIGHT:-}")" + printf ' "maxpreviews": %s,\n' "$(_json_string "${MAXPREVIEWS:-}")" + printf ' "image_jobs": %s,\n' "$(_json_string "${IMAGE_JOBS:-}")" + printf ' "random_seed": %s,\n' "$(_json_string "${RANDOM_SEED:-}")" + printf ' "shuffle": %s,\n' "$(_json_bool "${SHUFFLE:-no}")" + printf ' "splash_page": %s,\n' "$(_json_bool "${SPLASH_PAGE:-yes}")" + printf ' "original_basepath": %s\n' \ + "$(_json_string "${ORIGINAL_BASEPATH:-}")" + printf ' }\n' + printf '}\n' + } > "$DIST_DIR/photoalbum.json" +} + +prepare_generation_photo_assets() { + warn_unsupported_incoming_files + mkdir -p "$DIST_DIR/photos" + cleanphotos + scalephotos + create_all_photo_derivatives 'photos' 'thumbs' 'blurs' +} + +clear_rendered_html() { + find "$DIST_DIR" -type f -name '*.html' -delete +} + +create_generation_archive() { + local -r tarball_name="$1"; shift + + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + tarball "$tarball_name" + fi +} + +generate() { + local tarball_name='' + + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + tarball_name=$(generated_tarball_name) + log_verbose \ + "Tarball enabled; planned archive:" \ + "$(_display_path "$DIST_DIR/$tarball_name")" + else + log_verbose 'Tarball disabled; no archive will be created' + fi + + prepare_generation_photo_assets + clear_rendered_html + render_album_pages 'photos' '.' 'thumbs' 'blurs' '.' "$tarball_name" + create_generation_archive "$tarball_name" + write_generation_metadata "$tarball_name" +} + +refresh_splash() { + local tmp_html + local tmp_path + + tmp_path=$(mktemp "$DIST_DIR/.index.html.XXXXXX") + tmp_html=$(basename "$tmp_path") + + if render_album_splash_page 'photos' '.' 'blurs' '.' "$tmp_html"; then + mv "$DIST_DIR/$tmp_html" "$DIST_DIR/index.html" + log_info "Refreshed splash page $(_display_path "$DIST_DIR/index.html")" + return + fi + + rm -f "$DIST_DIR/$tmp_html" + return 1 +} + +dry_run() { + local -i image_count=0 + local -i html_index_count=1 + local -i page_count=0 + local -i redirect_count=0 + local -i details_count=0 + + image_count=$(count_incoming_images) + + if (( image_count > 0 )); then + details_count=$image_count + page_count=$(( (image_count + MAXPREVIEWS - 1) / MAXPREVIEWS )) + redirect_count=$(( page_count * 2 + 1 )) + 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 'Image jobs: %s\n' "$IMAGE_JOBS" + printf 'Random seed: %s\n' "${RANDOM_SEED:-}" + printf 'Shuffle: %s\n' "${SHUFFLE:-no}" + printf 'Splash page: %s\n' "${SPLASH_PAGE:-yes}" + 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 'Planned generated files:\n' + if [ "${SPLASH_PAGE:-yes}" = yes ]; then + printf ' %s/index.html (%s splash page)\n' \ + "$DIST_DIR" "$html_index_count" + else + printf ' %s/index.html (%s album index redirect)\n' \ + "$DIST_DIR" "$html_index_count" + fi + 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/page-*.html (%s preview pages)\n' "$DIST_DIR" "$page_count" + printf ' %s/[page]-[image].html (%s view pages)\n' \ + "$DIST_DIR" "$image_count" + printf ' %s/[page]-[image]-details.html (%s details pages)\n' \ + "$DIST_DIR" "$details_count" + printf ' %s/[redirect].html (%s navigation redirects)\n' \ + "$DIST_DIR" "$redirect_count" + if [ "${TARBALL_INCLUDE:-no}" = yes ]; then + printf ' %s/%s\n' "$DIST_DIR" "$(tarball_name_plan)" + fi +} diff --git a/src/lib/bootstrap.source.sh b/src/lib/bootstrap.source.sh new file mode 100644 index 0000000..bb309f9 --- /dev/null +++ b/src/lib/bootstrap.source.sh @@ -0,0 +1,173 @@ +usage() { + cat - <&2 + Usage: + $0 --generate [--config PATH] [OPTIONS] + $0 --refresh-splash [--config PATH] [OPTIONS] + $0 --sync [--config PATH] [OPTIONS] + $0 --dry-run [--config PATH] [OPTIONS] + $0 --print-config [--config PATH] [OPTIONS] + $0 --clean [--config PATH] [OPTIONS] + $0 --version + $0 --init + + Options: + --config PATH + --incoming PATH + --dist PATH + --template PATH + --title TEXT + --height VALUE + --thumbheight VALUE + --maxpreviews N + --image-jobs N + --random-seed VALUE + --splash + --no-splash + --shuffle + --no-shuffle + --tarball + --no-tarball + --force + --sync-destination DEST + --sync-delete + --no-sync-delete + --verbose + --quiet +USAGE +} + +output_is_quiet() { + [ "$PHOTOALBUM_OUTPUT_MODE" = quiet ] +} + +output_is_verbose() { + [ "$PHOTOALBUM_OUTPUT_MODE" = verbose ] +} + +log_info() { + if ! output_is_quiet; then + printf '%s\n' "$*" + fi +} + +log_verbose() { + if output_is_verbose; then + printf 'Verbose: %s\n' "$*" + fi +} + +log_warning() { + printf 'WARNING: %s\n' "$*" >&2 +} + +resolve_default_rc_file() { + local source_root + + if [ -f "$DEFAULTRC" ]; then + printf '%s\n' "$DEFAULTRC" + return + fi + + source_root=$(resolve_source_root) + + if [ -n "$source_root" ]; then + printf '%s\n' "$source_root/src/photoalbum.default.conf" + return + fi + + printf '%s\n' "$DEFAULTRC" +} + +resolve_source_root() { + local script_dir + local source_root + + script_dir="$PHOTOALBUM_SOURCE_DIR" + source_root=$(cd "$script_dir/.." && pwd) + + if [[ -f "$source_root/src/photoalbum.default.conf" \ + && -d "$source_root/share/templates/default" ]]; then + printf '%s\n' "$source_root" + fi +} + +resolve_default_template_dir() { + local source_root + + if [ -d "$DEFAULT_TEMPLATE_DIR" ]; then + printf '%s\n' "$DEFAULT_TEMPLATE_DIR" + return + fi + + source_root=$(resolve_source_root) + + if [ -n "$source_root" ]; then + printf '%s\n' "$source_root/share/templates/default" + return + fi + + printf '%s\n' "$DEFAULT_TEMPLATE_DIR" +} + +template_dir_uses_default() { + if [ -z "${TEMPLATE_DIR+x}" ] || [ -z "$TEMPLATE_DIR" ]; then + return 1 + fi + + case "$TEMPLATE_DIR" in + "$PACKAGED_TEMPLATE_DIR"|"$DEFAULT_TEMPLATE_DIR") + return 0 + ;; + *) + return 1 + ;; + esac +} + +apply_template_dir_default() { + if template_dir_uses_default; then + TEMPLATE_DIR=$(resolve_default_template_dir) + fi +} + +init_config() { + local -r rc_file=photoalbum.conf + local default_rc_file + local rewritten_rc_file + local source_root + local source_template_dir + + if [ -f "$rc_file" ]; then + printf 'Error: %s already exists\n' "$rc_file" >&2 + exit 1 + fi + + default_rc_file=$(resolve_default_rc_file) + + if [ ! -f "$default_rc_file" ]; then + printf 'Error: Can not find config file %s\n' "$default_rc_file" >&2 + exit 1 + fi + + cp "$default_rc_file" "$rc_file" + + source_root=$(resolve_source_root) + if [[ -n "$source_root" \ + && "$default_rc_file" = "$source_root/src/photoalbum.default.conf" ]]; then + source_template_dir="$source_root/share/templates/default" + rewritten_rc_file=$(mktemp "${rc_file}.XXXXXX") + if ! awk -v template_dir="$source_template_dir" \ + '/^TEMPLATE_DIR=/ { + print "TEMPLATE_DIR=" template_dir + next + } + { print }' "$rc_file" > "$rewritten_rc_file"; then + rm -f "$rewritten_rc_file" + exit 1 + fi + cat "$rewritten_rc_file" > "$rc_file" + rm -f "$rewritten_rc_file" + fi + + log_info "Created ./$rc_file" +} diff --git a/src/lib/config.source.sh b/src/lib/config.source.sh new file mode 100644 index 0000000..200bac5 --- /dev/null +++ b/src/lib/config.source.sh @@ -0,0 +1,679 @@ +print_shell_assignment() { + local -r name="$1"; shift + local -r value="$1"; shift + + printf '%s=%q\n' "$name" "$value" +} + +print_shell_array_assignment() { + local -r name="$1"; shift + local value + + printf '%s=(' "$name" + for value in "$@"; do + printf ' %q' "$value" + done + printf ' )\n' +} + +print_config() { + local -a tar_opts=() + local -a sync_destinations=() + + resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations + + print_shell_assignment CONFIG_SOURCE "${PHOTOALBUM_CONFIG_SOURCE:-}" + print_shell_assignment INCOMING_DIR "$INCOMING_DIR" + print_shell_assignment DIST_DIR "$DIST_DIR" + print_shell_assignment TEMPLATE_DIR "$TEMPLATE_DIR" + print_shell_assignment TITLE "$TITLE" + print_shell_assignment HEIGHT "${HEIGHT:-}" + print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT" + print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS" + print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS" + print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT" + print_shell_assignment RANDOM_SEED "${RANDOM_SEED:-}" + print_shell_assignment SHUFFLE "${SHUFFLE:-no}" + print_shell_assignment SPLASH_PAGE "${SPLASH_PAGE:-yes}" + print_shell_assignment TARBALL_INCLUDE "${TARBALL_INCLUDE:-no}" + print_shell_assignment TARBALL_SUFFIX "${TARBALL_SUFFIX:-.tar}" + print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" + print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" + print_shell_assignment SYNC_DELETE "${SYNC_DELETE:-yes}" + print_shell_array_assignment SYNC_DESTINATIONS "${sync_destinations[@]}" + print_shell_assignment ORIGINAL_BASEPATH "${ORIGINAL_BASEPATH:-}" +} + +sync_dist() { + local destination + local -a rsync_args=(-av) + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if [ "${SYNC_DELETE:-yes}" = yes ]; then + rsync_args+=(--delete) + fi + + for destination in "${sync_destinations[@]}"; do + log_info "Syncing $DIST_DIR/ to $destination" + rsync "${rsync_args[@]}" "$DIST_DIR/" "$destination" + done +} + +existing_parent_dir() { + local -r path="$1"; shift + local existing_parent + + existing_parent=$(dirname "$path") + + while [ ! -e "$existing_parent" ]; do + existing_parent=$(dirname "$existing_parent") + done + + printf '%s\n' "$existing_parent" +} + +generation_staging_dir() { + local -r final_dist="$1"; shift + local final_base + local staging_parent + + final_base=$(basename "$final_dist") + staging_parent=$(existing_parent_dir "$final_dist") + + mktemp -d "$staging_parent/.photoalbum.$final_base.staging.XXXXXX" +} + +prepare_generation_staging_dir() { + local -r final_dist="$1"; shift + local -r staging_dir="$1"; shift + local cache_dir + + if [ "$PHOTOALBUM_FORCE_GENERATE" = yes ]; then + log_verbose 'Force generation enabled; not reusing existing output cache' + return + fi + + for cache_dir in photos thumbs blurs .photoalbum-cache; do + if [ -d "$final_dist/$cache_dir" ]; then + if ! mkdir -p "$staging_dir/$cache_dir"; then + return 1 + fi + if ! cp -a "$final_dist/$cache_dir/." "$staging_dir/$cache_dir/"; then + return 1 + fi + fi + done +} + +cleanup_generation_staging_dir() { + if [ -n "${PHOTOALBUM_ACTIVE_STAGING_DIR:-}" ]; then + rm -rf "$PHOTOALBUM_ACTIVE_STAGING_DIR" + PHOTOALBUM_ACTIVE_STAGING_DIR='' + fi +} + +terminate_process_tree() { + local -r signal="$1"; shift + local -r pid="$1"; shift + local child + + if [ -z "$pid" ]; then + return + fi + + while IFS= read -r child; do + if [ -n "$child" ]; then + terminate_process_tree "$signal" "$child" + fi + done < <(pgrep -P "$pid" 2>/dev/null || true) + + kill "-$signal" "$pid" 2>/dev/null || true +} + +wait_for_process_exit() { + local -r pid="$1"; shift + local -i attempts=0 + + while kill -0 "$pid" 2>/dev/null; do + if (( attempts >= 20 )); then + return 1 + fi + (( ++attempts )) + sleep 0.05 + done +} + +terminate_active_generation() { + local -r pid="${PHOTOALBUM_ACTIVE_GENERATION_PID:-}" + + if [ -z "$pid" ]; then + return + fi + + terminate_process_tree TERM "$pid" + if ! wait_for_process_exit "$pid"; then + terminate_process_tree KILL "$pid" + fi + wait "$pid" 2>/dev/null || true + PHOTOALBUM_ACTIVE_GENERATION_PID='' +} + +handle_generation_staging_signal() { + local -r status="$1"; shift + + clear_generation_staging_traps + terminate_active_generation + cleanup_generation_staging_dir + exit "$status" +} + +clear_generation_staging_traps() { + trap - EXIT INT TERM HUP PIPE +} + +ignore_generation_staging_interrupts() { + trap '' INT TERM HUP PIPE +} + +replace_dist_with_staging() { + local -r staging_dir="$1"; shift + local -r final_dist="$1"; shift + local backup_dist='' + local backup_parent='' + local final_base + local final_parent + local staging_parent + local -i status=0 + + final_base=$(basename "$final_dist") + final_parent=$(dirname "$final_dist") + staging_parent=$(existing_parent_dir "$final_dist") + + if ! mkdir -p "$final_parent"; then + return 1 + fi + + if [ -e "$final_dist" ]; then + if ! backup_parent=$( + mktemp -d "$staging_parent/.photoalbum.$final_base.backup.XXXXXX" + ); then + return 1 + fi + backup_dist="$backup_parent/dist" + + if ! mv "$final_dist" "$backup_dist"; then + rm -rf "$backup_parent" + return 1 + fi + fi + + if mv "$staging_dir" "$final_dist"; then + if [ -n "$backup_parent" ]; then + rm -rf "$backup_parent" + fi + return 0 + else + status=$? + fi + + if [ -n "$backup_dist" ] && [ -e "$backup_dist" ]; then + if [ -e "$final_dist" ] && ! rm -rf "$final_dist"; then + printf 'ERROR: Failed to restore %s from %s\n' \ + "$final_dist" "$backup_dist" >&2 + return "$status" + fi + if ! mv "$backup_dist" "$final_dist"; then + printf 'ERROR: Failed to restore %s from %s\n' \ + "$final_dist" "$backup_dist" >&2 + return "$status" + fi + rm -rf "$backup_parent" + fi + + return "$status" +} + +generate_staged() { + local -r final_dist="$DIST_DIR" + local staging_dir + local -i status=0 + + staging_dir=$(generation_staging_dir "$final_dist") + log_verbose "Effective output directory: $final_dist" + log_verbose "Generation staging directory: $staging_dir" + PHOTOALBUM_ACTIVE_STAGING_DIR="$staging_dir" + trap cleanup_generation_staging_dir EXIT + trap 'handle_generation_staging_signal 130' INT + trap 'handle_generation_staging_signal 143' TERM + trap 'handle_generation_staging_signal 129' HUP + trap 'handle_generation_staging_signal 141' PIPE + + if ! prepare_generation_staging_dir "$final_dist" "$staging_dir"; then + cleanup_generation_staging_dir + clear_generation_staging_traps + return 1 + fi + + set +e + ( + set -e + PHOTOALBUM_FINAL_DIST_DIR="$final_dist" + export PHOTOALBUM_FINAL_DIST_DIR + DIST_DIR="$staging_dir" generate + ) & + PHOTOALBUM_ACTIVE_GENERATION_PID=$! + wait "$PHOTOALBUM_ACTIVE_GENERATION_PID" + status=$? + PHOTOALBUM_ACTIVE_GENERATION_PID='' + set -e + + if (( status != 0 )); then + cleanup_generation_staging_dir + clear_generation_staging_traps + return "$status" + fi + + ignore_generation_staging_interrupts + set +e + replace_dist_with_staging "$staging_dir" "$final_dist" + status=$? + set -e + + if (( status != 0 )); then + cleanup_generation_staging_dir + clear_generation_staging_traps + return "$status" + fi + + PHOTOALBUM_ACTIVE_STAGING_DIR='' + clear_generation_staging_traps +} + +resolve_config_file() { + local -r config_file="${1:-}" + + if [ -n "$config_file" ]; then + printf '%s\n' "$config_file" + else + printf '%s\n' ./photoalbum.conf + fi +} + +missing_config() { + local -r config_file="$1"; shift + + printf 'Error: Can not find config file %s\n' "$config_file" >&2 + printf 'Run photoalbum --init to create ./photoalbum.conf.\n' >&2 + exit 1 +} + +apply_config_defaults() { + HEIGHT="${HEIGHT:-}" + IMAGE_JOBS="${IMAGE_JOBS:-3}" + IMAGEMAGICK_TIMEOUT="${IMAGEMAGICK_TIMEOUT:-60}" + ORIGINAL_BASEPATH="${ORIGINAL_BASEPATH:-}" + RANDOM_SEED="${RANDOM_SEED:-}" + SHUFFLE="${SHUFFLE:-no}" + SPLASH_PAGE="${SPLASH_PAGE:-yes}" + SYNC_DELETE="${SYNC_DELETE:-yes}" + TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}" + TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" + TAR_TIMEOUT="${TAR_TIMEOUT:-120}" + if ! declare -p TAR_OPTS >/dev/null 2>&1; then + TAR_OPTS=(-c) + fi + if ! declare -p SYNC_DESTINATIONS >/dev/null 2>&1; then + SYNC_DESTINATIONS=() + fi +} + +option_value() { + local -r option="$1"; shift + local -r argument="${CLI_OPTION_ARGUMENT[$option]:-value}" + + if (( $# == 0 )) || [ -z "$1" ]; then + printf 'Error: %s requires a %s\n' "$option" "$argument" >&2 + usage + exit 1 + fi + + printf '%s\n' "$1" +} + +apply_cli_override() { + local -r config_target="$1"; shift + + if [[ -v "cli_overrides[$config_target]" ]]; then + printf -v "$config_target" '%s' "${cli_overrides[$config_target]}" + fi +} + +apply_cli_overrides() { + local config_target + + for config_target in "${CLI_CONFIG_OVERRIDE_TARGETS[@]}"; do + apply_cli_override "$config_target" + done + + if (( ${#cli_sync_destinations[@]} > 0 )); then + SYNC_DESTINATIONS=("${cli_sync_destinations[@]}") + fi +} + +config_error() { + local -r message="$1"; shift + + printf 'ERROR: %s\n' "$message" >&2 + return 1 +} + +require_config_var() { + local -r name="$1"; shift + + if [ -z "${!name+x}" ] || [ -z "${!name}" ]; then + config_error "$name must be set in photoalbum configuration" + fi +} + +validate_positive_integer_config_var() { + local -r name="$1"; shift + local -r value="${!name}" + + if [[ ! "$value" =~ ^[0-9]+$ ]] || (( value < 1 )); then + config_error "$name must be a positive integer" + fi +} + +validate_optional_positive_integer_config_var() { + local -r name="$1"; shift + + if [ -n "${!name}" ]; then + validate_positive_integer_config_var "$name" + fi +} + +validate_yes_no_config_var() { + local -r name="$1"; shift + local -r value="${!name}" + + case "$value" in + yes|no) + ;; + *) + config_error "$name must be yes or no" + ;; + esac +} + +validate_dist_dir() { + local existing_parent + + if [ -e "$DIST_DIR" ]; then + if [ ! -d "$DIST_DIR" ]; then + config_error "DIST_DIR $DIST_DIR must be a directory" + fi + if [[ ! -w "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then + config_error "DIST_DIR $DIST_DIR must be writable" + fi + + return + fi + + existing_parent=$(existing_parent_dir "$DIST_DIR") + + if [ ! -d "$existing_parent" ]; then + config_error "DIST_DIR parent $existing_parent must be a directory" + fi + if [[ ! -w "$existing_parent" || ! -x "$existing_parent" ]]; then + config_error "DIST_DIR parent $existing_parent must be writable" + fi +} + +validate_template_dir_access() { + if [[ ! -d "$TEMPLATE_DIR" || ! -r "$TEMPLATE_DIR" \ + || ! -x "$TEMPLATE_DIR" ]]; then + config_error "TEMPLATE_DIR $TEMPLATE_DIR must be a readable directory" + fi +} + +validate_template_file() { + local -r template_name="$1"; shift + + if [ ! -r "$TEMPLATE_DIR/$template_name.tmpl" ]; then + config_error \ + "template file $TEMPLATE_DIR/$template_name.tmpl must be readable" + fi +} + +validate_template_dir() { + local template_name + local -a required_templates=( + details + footer + header + next + prev + preview + redirect + view + ) + + validate_template_dir_access + + if [ "${SPLASH_PAGE:-yes}" = yes ]; then + required_templates+=(splash) + fi + + for template_name in "${required_templates[@]}"; do + validate_template_file "$template_name" + done +} + +validate_refresh_splash_config() { + local required_var + local -a required_vars=( + TITLE + DIST_DIR + TEMPLATE_DIR + ) + + for required_var in "${required_vars[@]}"; do + require_config_var "$required_var" + done + + validate_yes_no_config_var SPLASH_PAGE + + if [ "${SPLASH_PAGE:-yes}" != yes ]; then + config_error 'SPLASH_PAGE must be yes to refresh the splash page' + fi + + validate_dist_dir + + validate_template_dir_access + validate_template_file splash + + if [ ! -d "$DIST_DIR/photos" ]; then + config_error "DIST_DIR photos directory $DIST_DIR/photos must exist" + fi + + if [ ! -d "$DIST_DIR/blurs" ]; then + config_error "DIST_DIR blurs directory $DIST_DIR/blurs must exist" + fi +} + +validate_imagemagick() { + if command -v magick >/dev/null 2>&1; then + return + fi + if command -v convert >/dev/null 2>&1; then + return + fi + + config_error 'ImageMagick is required; install magick or convert' +} + +validate_common_config() { + local required_var + local -a required_vars=( + TITLE + THUMBHEIGHT + MAXPREVIEWS + IMAGE_JOBS + INCOMING_DIR + DIST_DIR + TEMPLATE_DIR + ) + + for required_var in "${required_vars[@]}"; do + require_config_var "$required_var" + done + + validate_optional_positive_integer_config_var HEIGHT + validate_positive_integer_config_var THUMBHEIGHT + validate_positive_integer_config_var MAXPREVIEWS + validate_positive_integer_config_var IMAGE_JOBS + validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT + validate_positive_integer_config_var TAR_TIMEOUT + validate_yes_no_config_var SHUFFLE + validate_yes_no_config_var SPLASH_PAGE + validate_yes_no_config_var TARBALL_INCLUDE +} + +validate_generation_config() { + local -r require_imagemagick="${1:-yes}" + + validate_common_config + + if [ ! -d "$INCOMING_DIR" ]; then + config_error "You have to create $INCOMING_DIR first" + fi + if [[ ! -r "$INCOMING_DIR" || ! -x "$INCOMING_DIR" ]]; then + config_error "INCOMING_DIR $INCOMING_DIR must be readable" + fi + + validate_dist_dir + validate_template_dir + if [ "$require_imagemagick" = yes ]; then + validate_imagemagick + fi +} + +validate_print_config() { + local -a tar_opts=() + local -a sync_destinations=() + + validate_common_config + resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations + validate_yes_no_config_var SYNC_DELETE +} + +validate_sync_destinations() { + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if (( ${#sync_destinations[@]} == 0 )); then + config_error 'SYNC_DESTINATIONS must contain at least one destination' + fi +} + +validate_rsync() { + if command -v rsync >/dev/null 2>&1; then + return + fi + + config_error 'rsync is required to sync generated output' +} + +validate_sync_config() { + require_config_var DIST_DIR + validate_yes_no_config_var SYNC_DELETE + validate_sync_destinations + + if [[ ! -d "$DIST_DIR" || ! -r "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then + config_error "DIST_DIR $DIST_DIR must be a readable directory" + fi + + validate_rsync +} + +set_cli_option_value() { + local -r option="$1"; shift + local -r value="$1"; shift + local config_target + + if [ "$option" = --sync-destination ]; then + cli_sync_destinations+=("$value") + has_config_overrides='yes' + return + fi + + config_target="${CLI_OPTION_CONFIG_TARGET[$option]:-}" + if [ -n "$config_target" ]; then + cli_overrides["$config_target"]="$value" + has_config_overrides='yes' + return + fi + + printf -v "${CLI_OPTION_TARGET[$option]}" '%s' "$value" +} + +set_cli_constant_option() { + local -r option="$1"; shift + local -r value="${CLI_OPTION_VALUE[$option]}" + local config_target + + config_target="${CLI_OPTION_CONFIG_TARGET[$option]:-}" + if [ -n "$config_target" ]; then + cli_overrides["$config_target"]="$value" + has_config_overrides='yes' + return + fi + + printf -v "${CLI_OPTION_TARGET[$option]}" '%s' "$value" +} + +set_cli_action() { + local -r selected_action="$1"; shift + + if [ -n "$action" ]; then + usage + exit 1 + fi + + action="$selected_action" +} + +parse_cli_arguments() { + local option_arg + local option + local option_kind + + while (( $# > 0 )); do + option="$1" + shift + + option_kind="${CLI_OPTION_KIND[$option]:-}" + case "$option_kind" in + value) + option_arg=$(option_value "$option" "$@") + set_cli_option_value "$option" "$option_arg" + shift + ;; + flag|output) + set_cli_constant_option "$option" + ;; + action) + set_cli_action "$option" + ;; + *) + usage + exit 1 + ;; + esac + done +} diff --git a/src/lib/image.source.sh b/src/lib/image.source.sh new file mode 100644 index 0000000..ccdca34 --- /dev/null +++ b/src/lib/image.source.sh @@ -0,0 +1,291 @@ +cleanphotos() { + local basename + local photo + local sub + + while IFS= read -r photo; do + basename=$(basename "$photo") + + if [[ -f "$INCOMING_DIR/$basename" ]] \ + && is_supported_image_file "$basename"; then + continue + fi + + log_info "Cleaning up $(_display_path "$photo")" + for sub in thumbs blurs photos; do + if [ -f "$DIST_DIR/$sub/$basename" ]; then + rm -f "$DIST_DIR/$sub/$basename" + log_info "removed '$(_display_path "$DIST_DIR/$sub/$basename")'" + fi + done + done < <(find "$DIST_DIR/photos" -maxdepth 1 -type f) +} + +is_supported_image_file() { + local -r file="$1"; shift + local extension + + if [[ "$file" != *.* ]]; then + return 1 + fi + + extension="${file##*.}" + extension="${extension,,}" + + case "$extension" in + gif|jpeg|jpg|png|webp) + return 0 + ;; + *) + return 1 + ;; + esac +} + +incoming_image_files() { + local file + + while IFS= read -r file; do + if is_supported_image_file "$file"; then + printf '%s\n' "$file" + fi + done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n') \ + | sort +} + +warn_unsupported_incoming_files() { + local file + + while IFS= read -r file; do + if ! is_supported_image_file "$file"; then + log_warning "Ignoring unsupported incoming file: $file" + fi + done < <(find "$INCOMING_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) +} + +scalephotos() { + local -i failed=0 + local -a image_job_pids=() + local photo + + while IFS= read -r photo; do + wait_for_image_job_slot image_job_pids failed + scale_photo "$photo" & + image_job_pids+=("$!") + done < <(incoming_image_files) + + wait_for_image_jobs image_job_pids failed + if (( failed != 0 )); then + return 1 + fi +} + +wait_for_image_job_pid() { + local -r pid="$1"; shift + local -i status=0 + + set +e + wait "$pid" + status=$? + set -e + + return "$status" +} + +image_job_pid_is_running() { + local -r pid="$1"; shift + local running_pid + + for running_pid in "$@"; do + if [ "$running_pid" = "$pid" ]; then + return 0 + fi + done + + return 1 +} + +reap_finished_image_jobs() { + local -r image_job_pids_name="$1"; shift + # shellcheck disable=SC2178 + local -n image_job_pids_ref="$image_job_pids_name" + local -n failed_ref="$1"; shift + local -i reaped_status="$1"; shift + local -i has_reaped_status=1 + local -i status=0 + local -a remaining_pids=() + local -a running_pids=() + local pid + + mapfile -t running_pids < <(jobs -rp) + + for pid in "${image_job_pids_ref[@]}"; do + if image_job_pid_is_running "$pid" "${running_pids[@]}"; then + remaining_pids+=("$pid") + continue + fi + + if wait_for_image_job_pid "$pid"; then + status=0 + else + status=$? + fi + if (( status == 127 && has_reaped_status != 0 )); then + status=$reaped_status + has_reaped_status=0 + fi + + if (( status != 0 )); then + failed_ref=1 + fi + done + + image_job_pids_ref=("${remaining_pids[@]}") +} + +wait_for_next_image_job() { + local -r image_job_pids_name="$1"; shift + local -r failed_name="$1"; shift + # shellcheck disable=SC2178 + local -n image_job_pids_ref="$image_job_pids_name" + local -i status=0 + + set +e + wait -n "${image_job_pids_ref[@]}" + status=$? + set -e + + reap_finished_image_jobs "$image_job_pids_name" "$failed_name" "$status" +} + +wait_for_image_job_slot() { + local -r image_job_pids_name="$1"; shift + local -r failed_name="$1"; shift + # shellcheck disable=SC2178 + local -n image_job_pids_ref="$image_job_pids_name" + local -r max_jobs="${IMAGE_JOBS:-3}" + + while (( ${#image_job_pids_ref[@]} >= max_jobs )); do + wait_for_next_image_job "$image_job_pids_name" "$failed_name" + done +} + +wait_for_image_jobs() { + # shellcheck disable=SC2178 + local -n image_job_pids_ref="$1"; shift + local -n failed_ref="$1"; shift + + : "$failed_ref" + while (( ${#image_job_pids_ref[@]} > 0 )); do + if ! wait_for_image_job_pid "${image_job_pids_ref[0]}"; then + failed_ref=1 + fi + image_job_pids_ref=("${image_job_pids_ref[@]:1}") + done +} + +scale_photo() { + local -r photo="$1"; shift + local destphoto + local dirname + + destphoto="$DIST_DIR/photos/$photo" + dirname=$(dirname "$destphoto") + mkdir -p "$dirname" + + if [ -f "$destphoto" ]; then + log_verbose "Skipped existing photo $(_display_path "$destphoto")" + return + fi + + log_info "Processing $photo to $(_display_path "$destphoto")" + if [ -n "${HEIGHT:-}" ]; then + # Scale down size. + imagemagick \ + "$INCOMING_DIR/$photo" \ + -auto-orient \ + -geometry "$HEIGHT" \ + "$destphoto" + else + # Keep original size. + imagemagick \ + "$INCOMING_DIR/$photo" \ + -auto-orient \ + "$destphoto" + fi +} + +random_seed_is_set() { + [ -n "${RANDOM_SEED:-}" ] +} + +deterministic_index() { + local -r namespace="$1"; shift + local -r count="$1"; shift + local checksum + + checksum=$(printf '%s' "${RANDOM_SEED}:$namespace" | cksum) + checksum=${checksum%% *} + + printf '%s\n' $(( checksum % count )) +} + +random_index() { + local -r namespace="$1"; shift + local -r count="$1"; shift + + if random_seed_is_set; then + deterministic_index "$namespace" "$count" + else + printf '%s\n' $(( RANDOM % count )) + fi +} + +random_animation_css_class() { + local -r speed="$1"; shift + local -r context="${1:-$speed}" + local -i index + local -a classes=( + "animate-opacity-$speed" + "animate-top-$speed" + "animate-left-$speed" + "animate-right-$speed" + "animate-bottom-$speed" + "animate-zoom-$speed" + "animate-snap-rotate-$speed" + "animate-hard-zoom-$speed" + "animate-slam-left-$speed" + "animate-slam-right-$speed" + "animate-flash-in-$speed" + "animate-invert-pop-$speed" + "animate-posterize-pop-$speed" + "animate-skew-snap-$speed" + "animate-glitch-step-$speed" + ) + + index=$(random_index "animation:$speed:$context" "${#classes[@]}") + printf '%s\n' "${classes[index]}" +} + +deterministic_shuffle() { + local checksum + local line + + while IFS= read -r line; do + checksum=$(printf '%s' "${RANDOM_SEED}:shuffle:$line" | cksum) + checksum=${checksum%% *} + printf '%010u\t%s\n' "$checksum" "$line" + done | sort -n -k1,1 -k2,2 | cut -f2- +} + +maybe_shuffle() { + if [ "${SHUFFLE:-no}" = yes ]; then + if random_seed_is_set; then + deterministic_shuffle + else + sort -R + fi + else + sort + fi +} diff --git a/src/lib/system.source.sh b/src/lib/system.source.sh new file mode 100644 index 0000000..9fad90b --- /dev/null +++ b/src/lib/system.source.sh @@ -0,0 +1,172 @@ +# shellcheck disable=SC2034 +resolve_imagemagick_command() { + local -r operation="$1"; shift + local -n command_ref="$1"; shift + + command_ref=() + + case "$operation" in + convert) + if command -v magick >/dev/null 2>&1; then + command_ref=(magick) + return + fi + if command -v convert >/dev/null 2>&1; then + command_ref=(convert) + return + fi + ;; + identify) + if command -v magick >/dev/null 2>&1; then + command_ref=(magick identify) + return + fi + if command -v identify >/dev/null 2>&1; then + command_ref=(identify) + return + fi + if command -v convert >/dev/null 2>&1; then + command_ref=(convert) + return + fi + ;; + *) + printf 'ERROR: Unknown ImageMagick operation %s\n' \ + "$operation" >&2 + return 1 + ;; + esac + + printf 'ERROR: ImageMagick is required; install magick or convert\n' >&2 + return 127 +} + +imagemagick() { + local -a imagemagick_command=() + + resolve_imagemagick_command convert imagemagick_command || return + + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + "${imagemagick_command[@]}" "$@" +} + +imagemagick_identify() { + local -a imagemagick_command=() + + resolve_imagemagick_command identify imagemagick_command || return + + if [ "${imagemagick_command[0]}" = convert ]; then + if [[ "${1:-}" = '-verbose' && $# -eq 2 ]]; then + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + "${imagemagick_command[@]}" "$2" -verbose info: + else + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + "${imagemagick_command[@]}" "$@" info: + fi + return + fi + + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + "${imagemagick_command[@]}" "$@" +} + +run_with_timeout() { + local -r description="$1"; shift + local -r seconds="$1"; shift + local -i status=0 + + if ! command -v timeout >/dev/null 2>&1; then + printf 'ERROR: timeout command is required to run %s\n' \ + "$description" >&2 + return 127 + fi + + if timeout "$seconds" "$@"; then + return + else + status=$? + fi + + if (( status == 124 )); then + printf 'ERROR: %s timed out after %s seconds\n' \ + "$description" "$seconds" >&2 + fi + + return "$status" +} + +tarball() { + local -r tarball_name="$1"; shift + local -r tarball_suffix="${TARBALL_SUFFIX:-.tar}" + local base + local old_tarball + local -a tar_opts=() + + # Cleanup tarballs from previous runs for the configured suffix. + while IFS= read -r -d '' old_tarball; do + if [[ "$old_tarball" == *"$tarball_suffix" ]]; then + rm -f "$old_tarball" + fi + done < <(find "$DIST_DIR"