diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-18 16:22:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-18 16:22:22 +0300 |
| commit | 3a3ae4a4424136ad4a5be636c06fe036711558a2 (patch) | |
| tree | 7b2cfe72bec40436196b7ae984ba126a401d2ae7 /src | |
| parent | 42036d5ec36160a9caa6de64f9476726a5386a69 (diff) | |
Add CHRONOLOGICAL_ORDER config option to order albums by EXIF date taken
Adds a new yes/no config setting (default no, preserving current behavior)
that orders the main album's photos by EXIF date taken (ascending) instead
of the default filename/shuffle order. Reuses the existing EXIF cache and
tag fallback chain (DateTimeOriginal -> DateTimeDigitized -> DateTime)
already used for tooltips/details/stats, so ordering never disagrees with
what those features show. Photos with no usable EXIF date fall back to
their source file's mtime, staying fully deterministic and crash-free.
CHRONOLOGICAL_ORDER takes precedence over SHUFFLE when both are enabled,
documented in album-photo-select.source.sh and docs/configuration.md.
Wired through the config registry (CONFIG_SPECS), validation, CLI flags
(--chronological/--no-chronological), --print-config, --dry-run,
--verbose logging, and shuriken.json generation metadata. Adds unit and
end-to-end tests covering default-off behavior, EXIF-date ordering with
shuffle precedence, and mtime fallback for EXIF-less photos.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/action.source.sh | 1 | ||||
| -rw-r--r-- | src/lib/album-metadata.source.sh | 23 | ||||
| -rw-r--r-- | src/lib/album-photo-select.source.sh | 90 | ||||
| -rw-r--r-- | src/lib/bootstrap.source.sh | 2 | ||||
| -rw-r--r-- | src/lib/config.spec.source.sh | 1 | ||||
| -rw-r--r-- | src/lib/config.validate.source.sh | 1 | ||||
| -rw-r--r-- | src/lib/dry-run.source.sh | 2 | ||||
| -rw-r--r-- | src/lib/generation-metadata.source.sh | 3 | ||||
| -rw-r--r-- | src/shuriken.default.conf | 11 | ||||
| -rwxr-xr-x | src/shuriken.sh | 2 |
10 files changed, 129 insertions, 7 deletions
diff --git a/src/lib/action.source.sh b/src/lib/action.source.sh index cbc928e..2b7a6bd 100644 --- a/src/lib/action.source.sh +++ b/src/lib/action.source.sh @@ -176,6 +176,7 @@ log_configured_action() { log_verbose "Effective image jobs: $IMAGE_JOBS" log_verbose "Effective ImageMagick timeout: ${IMAGEMAGICK_TIMEOUT}s" log_verbose "Effective tar timeout: ${TAR_TIMEOUT}s" + log_verbose "Effective chronological order setting: $CHRONOLOGICAL_ORDER" log_verbose "Effective splash page setting: $SPLASH_PAGE" log_verbose "Effective details page setting: $DETAILS_PAGE" log_verbose "Effective stats page setting: $STATS_PAGE" diff --git a/src/lib/album-metadata.source.sh b/src/lib/album-metadata.source.sh index 0d93935..2a3abab 100644 --- a/src/lib/album-metadata.source.sh +++ b/src/lib/album-metadata.source.sh @@ -188,3 +188,26 @@ photo_exif_tooltip_text() { _photo_exif_values_to exif_values "$photo" "$photo_path" _photo_exif_tooltip_text_from_values exif_values } + +# A photo's EXIF date-taken, in the raw "YYYY:MM:DD HH:MM:SS" EXIF format. +# Consumed by CHRONOLOGICAL_ORDER (album-photo-select.source.sh, +# chronological_sort_key_for_photo) so ordering by "date taken" uses exactly the +# same tag fallback chain (DateTimeOriginal -> DateTimeDigitized -> DateTime) as +# the tooltip's "Taken:" field above -- one photo can't disagree with itself +# about when it was taken depending on which feature asks. Empty when none of +# the three tags is present (e.g. screenshots, downloaded images); callers +# needing a value in that case supply their own deterministic fallback rather +# than guessing a date here. +photo_date_taken() { + local -r photo="$1"; shift + local -r photo_path="$1"; shift + # exif_values is populated and read through nameref helpers. + # shellcheck disable=SC2034 + local -A exif_values=() + local date_time + + _photo_exif_values_to exif_values "$photo" "$photo_path" + _first_exif_value_to date_time exif_values \ + DateTimeOriginal DateTimeDigitized DateTime + printf '%s\n' "$date_time" +} diff --git a/src/lib/album-photo-select.source.sh b/src/lib/album-photo-select.source.sh index ea661b4..283e17b 100644 --- a/src/lib/album-photo-select.source.sh +++ b/src/lib/album-photo-select.source.sh @@ -2,24 +2,102 @@ # album-render.source.sh (task ar0) so the "which photos are in this album, in # what order, and which one do we pick for a background/splash" concern lives # apart from the page orchestration, the tile-layout deciders and the thumbnail -# HTML. This is selection POLICY (shuffle/sort, splash-requires-a-blur, seeded -# random pick) and changes for different reasons than the rendering plumbing. +# HTML. This is selection POLICY (shuffle/sort/chronological, splash-requires-a- +# blur, seeded random pick) and changes for different reasons than the +# rendering plumbing. # # These helpers are called by the orchestrator (album-render.source.sh) and by # the per-page render jobs at runtime; all libs are sourced before any code runs, # so availability does not depend on source order. -# Unlike the other photo listings this one keeps its own find rather than using -# list_photos (photo-list.source.sh): it pipes through maybe_shuffle, not sort, -# because the album's display order is the configurable (seeded) shuffle, not a -# plain sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action. +# Main album display order, in precedence order (task 8v0): +# 1. CHRONOLOGICAL_ORDER=yes -> chronological_photo_files (EXIF date taken, +# ascending, falling back to mtime for photos without one). +# 2. otherwise -> the historical maybe_shuffle path (seeded/random SHUFFLE, or +# plain filename sort when SHUFFLE=no). +# CHRONOLOGICAL_ORDER therefore takes precedence over SHUFFLE when both are +# set: a chronological album is meant to read as a timeline, so an enabled +# shuffle must not silently re-scramble it. This is deliberately a config-level +# choice rather than an error, so flipping SHUFFLE on/off (e.g. via the CLI +# flags) while experimenting does not require also touching +# CHRONOLOGICAL_ORDER. Unlike the other photo listings this one keeps its own +# find rather than using list_photos (photo-list.source.sh): both order modes +# need the raw filename list before applying their own ordering, not a plain +# sort. Uses $FIND (compat.source.sh) since -printf is a GNU-only action. album_photo_files() { local -r photos_dir="$1"; shift + if [ "$CHRONOLOGICAL_ORDER" = yes ]; then + chronological_photo_files "$photos_dir" + return + fi + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ | maybe_shuffle } +# Build the sort key chronological_photo_files uses to order one photo: a +# tab-separated "<group>\t<time>\t<photo>" line consumed by a plain lexicographic +# sort (see chronological_photo_files). EXIF reads always target the INCOMING_DIR +# original (matching photo_exif_tooltip_text/photo_exif_details_html), not the +# resized DIST_DIR copy, so ordering and tooltip/details agree about a photo's +# taken time and both share the same identify cache entry. +# +# group 0 when photo_date_taken (album-metadata.source.sh) found a real EXIF +# date, 1 otherwise. Group 0 always sorts before group 1, so photos +# with a genuine timestamp are never displaced by an approximate +# fallback for photos that lack one. +# time the EXIF date normalized from "YYYY:MM:DD HH:MM:SS" to a 14-digit +# "YYYYMMDDHHMMSS" string (colons/space just stripped -- the calendar +# substrings are untouched, so this stays safe even though the EXIF +# string is not `date -d`-parseable, see docs/stats-exif-audit.md) for +# group 0, or the INCOMING_DIR file's mtime (compat.source.sh $STAT, +# zero-padded so it sorts lexicographically) for group 1. Fixed width +# within each group keeps a plain sort numerically correct. +# photo final tiebreaker so photos sharing a timestamp (e.g. burst shots) or +# missing both an EXIF date and a readable mtime still sort in a +# stable, reproducible order across regenerations of the same +# incoming set. +chronological_sort_key_for_photo() { + local -r photo="$1"; shift + local date_time + local mtime + + date_time=$(photo_date_taken "$photo" "$INCOMING_DIR/$photo") + if [[ "$date_time" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})\ ([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then + printf '0\t%s%s%s%s%s%s\t%s\n' \ + "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" \ + "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" "${BASH_REMATCH[6]}" \ + "$photo" + return + fi + + # No usable EXIF date-taken (missing tag or a malformed value): fall back to + # the source file's mtime so ordering still reflects "roughly when this + # photo appeared" rather than an arbitrary readdir order, and stays fully + # deterministic across runs. A missing/unreadable source file (should not + # happen; INCOMING_DIR is validated before generation) reads as mtime 0 so + # this never aborts the render. + mtime=$("$STAT" -c '%Y' "$INCOMING_DIR/$photo" 2>/dev/null) || mtime=0 + printf '1\t%020d\t%s\n' "$mtime" "$photo" +} + +# Chronological ordering for CHRONOLOGICAL_ORDER=yes: every photo in +# DIST_DIR/photos_dir, ordered ascending by chronological_sort_key_for_photo. +# Explicit tab delimiter (rather than plain whitespace splitting) so a filename +# containing a space (e.g. the "04 filename with spaces.jpg" test fixture) stays +# one field instead of fracturing the sort/cut boundaries. +chronological_photo_files() { + local -r photos_dir="$1"; shift + + "$FIND" "$DIST_DIR/$photos_dir" -maxdepth 1 -type f -printf '%f\n' \ + | while IFS= read -r photo; do + chronological_sort_key_for_photo "$photo" + done \ + | sort -t $'\t' -k1,1 -k2,2 \ + | cut -f3- +} + # Pagination single source of truth (task nr0): how many preview pages a given # number of album photos splits into, with at most MAXPREVIEWS photos per page. # album_page_records below realises exactly this many records by grouping the diff --git a/src/lib/bootstrap.source.sh b/src/lib/bootstrap.source.sh index 1cb40eb..35ac169 100644 --- a/src/lib/bootstrap.source.sh +++ b/src/lib/bootstrap.source.sh @@ -32,6 +32,8 @@ usage() { --feature PERCENT --image-jobs N --random-seed VALUE + --chronological + --no-chronological --splash --no-splash --details diff --git a/src/lib/config.spec.source.sh b/src/lib/config.spec.source.sh index 764cba6..c7e90f7 100644 --- a/src/lib/config.spec.source.sh +++ b/src/lib/config.spec.source.sh @@ -71,6 +71,7 @@ declare -gra CONFIG_SPECS=( 'IMAGE_JOBS|3|yes|yes|required-posint|scalar' 'IMAGEMAGICK_TIMEOUT|60|yes|no|posint|scalar' 'RANDOM_SEED||yes|yes||scalar' + 'CHRONOLOGICAL_ORDER|no|yes|yes|yesno|scalar' 'SHUFFLE|no|yes|yes|yesno|scalar' 'SPLASH_PAGE|yes|yes|yes|yesno|scalar' 'DETAILS_PAGE|yes|yes|yes|yesno|scalar' diff --git a/src/lib/config.validate.source.sh b/src/lib/config.validate.source.sh index 345ec6d..66e49fe 100644 --- a/src/lib/config.validate.source.sh +++ b/src/lib/config.validate.source.sh @@ -298,6 +298,7 @@ validate_common_config() { IMAGEMAGICK_TIMEOUT TAR_TIMEOUT SYNC_TIMEOUT + CHRONOLOGICAL_ORDER SHUFFLE SPLASH_PAGE DETAILS_PAGE diff --git a/src/lib/dry-run.source.sh b/src/lib/dry-run.source.sh index 4bdb66e..0c841f9 100644 --- a/src/lib/dry-run.source.sh +++ b/src/lib/dry-run.source.sh @@ -89,6 +89,7 @@ collect_dry_run_plan() { plan_ref["feature_percent"]="$THUMB_FEATURE_PERCENT" plan_ref["image_jobs"]="$IMAGE_JOBS" plan_ref["random_seed"]="$RANDOM_SEED" + plan_ref["chronological_order"]="$CHRONOLOGICAL_ORDER" plan_ref["shuffle"]="$SHUFFLE" plan_ref["splash_page"]="$SPLASH_PAGE" plan_ref["details_page"]="$DETAILS_PAGE" @@ -124,6 +125,7 @@ _print_dry_run_settings() { printf 'Feature percent: %s\n' "${plan_ref["feature_percent"]}" printf 'Image jobs: %s\n' "${plan_ref["image_jobs"]}" printf 'Random seed: %s\n' "${plan_ref["random_seed"]}" + printf 'Chronological order: %s\n' "${plan_ref["chronological_order"]}" printf 'Shuffle: %s\n' "${plan_ref["shuffle"]}" printf 'Splash page: %s\n' "${plan_ref["splash_page"]}" printf 'Details page: %s\n' "${plan_ref["details_page"]}" diff --git a/src/lib/generation-metadata.source.sh b/src/lib/generation-metadata.source.sh index 20f5d09..c765f1c 100644 --- a/src/lib/generation-metadata.source.sh +++ b/src/lib/generation-metadata.source.sh @@ -42,6 +42,7 @@ _collect_generation_metadata() { _GENERATION_METADATA["settings_feature_percent"]="$THUMB_FEATURE_PERCENT" _GENERATION_METADATA["settings_image_jobs"]="$IMAGE_JOBS" _GENERATION_METADATA["settings_random_seed"]="$RANDOM_SEED" + _GENERATION_METADATA["settings_chronological_order"]="$CHRONOLOGICAL_ORDER" _GENERATION_METADATA["settings_shuffle"]="$SHUFFLE" _GENERATION_METADATA["settings_splash_page"]="$SPLASH_PAGE" _GENERATION_METADATA["settings_details_page"]="$DETAILS_PAGE" @@ -112,6 +113,8 @@ _generation_metadata_json_settings() { "$(json_string "${_GENERATION_METADATA["settings_image_jobs"]}")" printf ' "random_seed": %s,\n' \ "$(json_string "${_GENERATION_METADATA["settings_random_seed"]}")" + printf ' "chronological_order": %s,\n' \ + "$(json_bool "${_GENERATION_METADATA["settings_chronological_order"]}")" printf ' "shuffle": %s,\n' \ "$(json_bool "${_GENERATION_METADATA["settings_shuffle"]}")" printf ' "splash_page": %s,\n' \ diff --git a/src/shuriken.default.conf b/src/shuriken.default.conf index 79413a5..63a1b1e 100644 --- a/src/shuriken.default.conf +++ b/src/shuriken.default.conf @@ -21,7 +21,16 @@ THUMB_FEATURE_PERCENT=10 IMAGE_JOBS=3 # Timeout in seconds for each ImageMagick command. IMAGEMAGICK_TIMEOUT=60 -# Randomly shuffle all previews. +# Order the main album's photos chronologically by EXIF date taken (ascending), +# falling back to the source file's modification time for photos with no usable +# EXIF date. Off by default (preserves the existing filename/shuffle order). +# When enabled, this takes precedence over SHUFFLE below -- a chronological +# album is meant to read as a timeline, so an enabled shuffle is ignored rather +# than re-scrambling it. +# CHRONOLOGICAL_ORDER=yes + +# Randomly shuffle all previews. Ignored when CHRONOLOGICAL_ORDER=yes (see +# above). # SHUFFLE=yes # Generate a splash landing page at index.html. SPLASH_PAGE=yes diff --git a/src/shuriken.sh b/src/shuriken.sh index 65dae43..8649a54 100755 --- a/src/shuriken.sh +++ b/src/shuriken.sh @@ -51,6 +51,8 @@ declare -Ar CLI_OPTION_SPEC=( [--feature]='kind=value config=THUMB_FEATURE_PERCENT' [--image-jobs]='kind=value config=IMAGE_JOBS' [--random-seed]='kind=value config=RANDOM_SEED' + [--chronological]='kind=flag value=yes config=CHRONOLOGICAL_ORDER' + [--no-chronological]='kind=flag value=no config=CHRONOLOGICAL_ORDER' [--shuffle]='kind=flag value=yes config=SHUFFLE' [--no-shuffle]='kind=flag value=no config=SHUFFLE' [--splash]='kind=flag value=yes config=SPLASH_PAGE' |
