diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rwxr-xr-x | bin/shuriken | 125 | ||||
| -rw-r--r-- | docs/configuration.md | 35 | ||||
| -rw-r--r-- | docs/generation.md | 12 | ||||
| -rw-r--r-- | docs/usage.md | 2 | ||||
| -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 | ||||
| -rwxr-xr-x | tests/cli.sh | 196 |
16 files changed, 491 insertions, 23 deletions
@@ -98,7 +98,8 @@ on Linux the default tools already are GNU, on macOS/FreeBSD install the Common per-run overrides (see the full reference table in [docs/usage.md](docs/usage.md)): `--incoming`, `--dist`, `--template`, `--title`, `--height`, `--thumbheight`, -`--maxpreviews`, `--image-jobs`, `--random-seed`, `--shuffle`/`--no-shuffle`, +`--maxpreviews`, `--image-jobs`, `--random-seed`, +`--chronological`/`--no-chronological`, `--shuffle`/`--no-shuffle`, `--splash`/`--no-splash`, `--details`/`--no-details`, `--stats`/`--no-stats`, `--tarball`/`--no-tarball`, `--favicon`, `--source-url`, `--sync-destination`, `--sync-delete`/`--no-sync-delete`, `--quiet`, `--verbose`. @@ -120,6 +121,11 @@ Feature toggles at a glance: * **Reproducible builds**: set `RANDOM_SEED` (or `--random-seed VALUE`) to make splash/background picks, animation classes, timestamps, and shuffle order repeatable. +* **Chronological order** (`CHRONOLOGICAL_ORDER=no`, the default): set to + `yes` (or pass `--chronological`) to order the main album's photos by EXIF + date taken instead of filename/shuffle order, falling back to source mtime + for photos with no usable EXIF date. Takes precedence over `SHUFFLE` when + both are enabled. ## Documentation diff --git a/bin/shuriken b/bin/shuriken index 5b8f974..fd7eda1 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -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' @@ -294,6 +296,8 @@ usage() { --feature PERCENT --image-jobs N --random-seed VALUE + --chronological + --no-chronological --splash --no-splash --details @@ -2573,6 +2577,29 @@ photo_exif_tooltip_text() { _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" +} + # Inlined from src/lib/generation-metadata.source.sh # Generation metadata: collect a snapshot of the run (generator version, source # and generated file counts, effective settings) and serialise it to the @@ -2618,6 +2645,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" @@ -2688,6 +2716,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' \ @@ -2810,6 +2840,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" @@ -2845,6 +2876,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"]}" @@ -3498,24 +3530,102 @@ build_preview_thumbnail() { # 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 @@ -5952,6 +6062,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' @@ -6887,6 +6998,7 @@ validate_common_config() { IMAGEMAGICK_TIMEOUT TAR_TIMEOUT SYNC_TIMEOUT + CHRONOLOGICAL_ORDER SHUFFLE SPLASH_PAGE DETAILS_PAGE @@ -7310,6 +7422,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/docs/configuration.md b/docs/configuration.md index e07227a..c2261c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,8 @@ values for the current run. | `IMAGE_JOBS` | `3` | Parallel jobs for image processing and HTML template rendering. Positive integer. | | `IMAGEMAGICK_TIMEOUT` | `60` | Per-ImageMagick-command timeout in seconds. Positive integer. | | `TAR_TIMEOUT` | `120` | Tarball creation timeout in seconds. Positive integer. | -| `SHUFFLE` | `no` | Randomly shuffle all previews. `yes`/`no`. | +| `CHRONOLOGICAL_ORDER` | `no` | Order the main album's photos chronologically by EXIF date taken (ascending), falling back to source mtime when a photo has no usable EXIF date. `yes`/`no`. Takes precedence over `SHUFFLE` when both are set. See "Photo ordering" below. | +| `SHUFFLE` | `no` | Randomly shuffle all previews. `yes`/`no`. Ignored when `CHRONOLOGICAL_ORDER=yes`. | | `SPLASH_PAGE` | `yes` | Generate a splash landing page at `index.html`. `yes`/`no`. | | `DETAILS_PAGE` | `yes` | Generate each photo's `*-details.html` page (and its "Details" link). `yes`/`no`. See "Details pages" below. | | `STATS_PAGE` | `no` | Generate the EXIF stats site under `stats/`. `yes`/`no`. | @@ -65,6 +66,26 @@ does not leave stale `*-details.html` files behind: generation stages the new output in a fresh directory and atomically replaces `DIST_DIR`, so files an older generation wrote but the current run does not produce are naturally gone. +## Photo ordering + +By default the main album's photos are listed in plain filename order, or in a +random/seeded shuffle when `SHUFFLE=yes` (see [generation.md](generation.md) +for reproducibility). Set `CHRONOLOGICAL_ORDER=yes` (or pass `--chronological`) +to instead order them chronologically by EXIF date taken (ascending), reusing +the same `DateTimeOriginal` -> `DateTimeDigitized` -> `DateTime` tag fallback +chain, and the same per-photo EXIF cache, as the "Taken:" tooltip field and the +details page. A photo with none of those three tags falls back to its source +file's modification time, so ordering is always fully deterministic and never +crashes on EXIF-less photos (screenshots, downloaded images, ...); photos with +a real EXIF date always sort before mtime-fallback photos, so an approximate +fallback never displaces a genuine timestamp. + +**`CHRONOLOGICAL_ORDER` takes precedence over `SHUFFLE`** when both are set to +`yes`: a chronological album is meant to read as a timeline, so an enabled +shuffle is silently ignored rather than re-scrambling it. This is a config-level +choice, not a validation error, so toggling `SHUFFLE` while experimenting does +not require also touching `CHRONOLOGICAL_ORDER`. + ## Supported source images Only regular files found directly in `INCOMING_DIR` (not in subdirectories) with @@ -84,8 +105,9 @@ The checks (details in `src/lib/config.validate.source.sh`): `IMAGEMAGICK_TIMEOUT`, `TAR_TIMEOUT`; `HEIGHT` is an optional positive integer. * **Percentage (0-100 integer)**: `THUMB_SUBDIVIDE_PERCENT`, `THUMB_FEATURE_PERCENT`. -* **`yes`/`no` settings**: `SHUFFLE`, `SPLASH_PAGE`, `DETAILS_PAGE`, - `STATS_PAGE`, `TARBALL_INCLUDE`, `SYNC_DELETE` (where applicable). +* **`yes`/`no` settings**: `CHRONOLOGICAL_ORDER`, `SHUFFLE`, `SPLASH_PAGE`, + `DETAILS_PAGE`, `STATS_PAGE`, `TARBALL_INCLUDE`, `SYNC_DELETE` (where + applicable). * **Readable input**: `INCOMING_DIR` must be a readable directory; `TEMPLATE_DIR` must be a readable directory containing the required templates (plus `splash` when `SPLASH_PAGE=yes`, and `details` when `DETAILS_PAGE=yes`). @@ -107,9 +129,10 @@ Generation stops before writing album output when validation fails. `CONFIG_SOURCE`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `FAVICON`, `SOURCE_URL`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, `THUMB_SUBDIVIDE_PERCENT`, `THUMB_FEATURE_PERCENT`, `IMAGE_JOBS`, -`IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, `DETAILS_PAGE`, -`STATS_PAGE`, `TARBALL_INCLUDE`, `TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, -`SYNC_DELETE`, `SYNC_DESTINATIONS`, `ORIGINAL_BASEPATH`. +`IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `CHRONOLOGICAL_ORDER`, `SHUFFLE`, +`SPLASH_PAGE`, `DETAILS_PAGE`, `STATS_PAGE`, `TARBALL_INCLUDE`, +`TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, `SYNC_DELETE`, +`SYNC_DESTINATIONS`, `ORIGINAL_BASEPATH`. Scalar values use Bash `%q` quoting; `TAR_OPTS` and `SYNC_DESTINATIONS` are normalized to Bash array assignments, so the output can be parsed by shell diff --git a/docs/generation.md b/docs/generation.md index 126f2bd..3941800 100644 --- a/docs/generation.md +++ b/docs/generation.md @@ -90,6 +90,12 @@ timestamps, and `--shuffle` preview order remain non-deterministic. Set choices repeatable for stable tests or reproducible album builds. Use the same seed and inputs to produce the same HTML. +`CHRONOLOGICAL_ORDER=yes` (see "Photo ordering" in +[configuration.md](configuration.md)) is always deterministic regardless of +`RANDOM_SEED`: it orders by each photo's EXIF date taken (with a source-mtime +fallback), so it needs no seed to repeat, and it takes precedence over +`SHUFFLE` when both are enabled. + ## Parallelism and timeouts ImageMagick photo processing and per-photo HTML template rendering run in @@ -111,9 +117,9 @@ metadata records: * generated photo, thumbnail, and HTML file counts; * tarball status (included + file); * effective settings (title, height, thumbheight, maxpreviews, subdivide - percent, feature percent, image jobs, random seed, shuffle, splash page, - details page, stats page, original basepath) useful for debugging a published - album. + percent, feature percent, image jobs, random seed, chronological order, + shuffle, splash page, details page, stats page, original basepath) useful for + debugging a published album. ## Favicon diff --git a/docs/usage.md b/docs/usage.md index 9436e36..5cb35b5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -72,6 +72,8 @@ config variable documented in [configuration.md](configuration.md). | `--feature PERCENT` | `THUMB_FEATURE_PERCENT` | | `--image-jobs N` | `IMAGE_JOBS` | | `--random-seed VALUE` | `RANDOM_SEED` | +| `--chronological` | `CHRONOLOGICAL_ORDER=yes` | +| `--no-chronological` | `CHRONOLOGICAL_ORDER=no` | | `--shuffle` | `SHUFFLE=yes` | | `--no-shuffle` | `SHUFFLE=no` | | `--splash` | `SPLASH_PAGE=yes` | 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' diff --git a/tests/cli.sh b/tests/cli.sh index c248b4c..0618163 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -142,6 +142,7 @@ assert metadata["settings"]["maxpreviews"] == maxpreviews assert metadata["settings"]["subdivide_percent"] == "30" assert metadata["settings"]["feature_percent"] == "10" assert metadata["settings"]["image_jobs"] == "3" +assert metadata["settings"]["chronological_order"] is False assert metadata["settings"]["shuffle"] is False assert isinstance(metadata["settings"]["splash_page"], bool) assert isinstance(metadata["settings"]["details_page"], bool) @@ -1065,6 +1066,118 @@ test_generate_random_seed_repeats_html_with_shuffle() { test::teardown } +# task 8v0: seed the six generate_fixture_images photos' EXIF cache entries with +# a DateTimeOriginal that runs in the OPPOSITE order of their filenames (and of +# the seeded shuffle used in the precedence test below), so neither a filename- +# sort bug nor a shuffle-precedence bug could make either assertion pass by +# accident. Cache files are pre-seeded (rather than routed through the fake +# ImageMagick identify) using the same photo_cache_signature-based pattern as +# test_stats_collect_reads_cached_identify_output, so the real --generate run +# never has to invoke identify at all. +test::seed_chronological_fixture_cache() { + local -r incoming_dir="$1"; shift + local -r cache_dir="$1"; shift + + mkdir -p "$cache_dir" + ( + test::source_shuriken_lib + seed_one() { + local -r photo="$1"; shift + local -r date_taken="$1"; shift + { + photo_cache_signature "$photo" "$incoming_dir/$photo" + printf ' exif:DateTimeOriginal: %s\n' "$date_taken" + } > "$cache_dir/$photo.txt" + } + seed_one '06-extra.jpg' '2019:01:01 08:00:00' + seed_one '05-extra.jpg' '2020:02:02 08:00:00' + seed_one '04 filename with spaces.jpg' '2021:03:03 08:00:00' + seed_one '03-square.jpg' '2022:04:04 08:00:00' + seed_one '02-portrait.jpg' '2023:05:05 08:00:00' + seed_one '01-landscape.jpg' '2024:06:06 08:00:00' + ) +} + +test_generate_chronological_order_sorts_by_exif_date_and_overrides_shuffle() { + local config_file + local fake_bin + local page_html + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/shuriken.conf" + + test::install_fake_imagemagick "$fake_bin" + PATH="$fake_bin:$PATH" \ + test::generate_fixture_images "$TEST_TMPDIR/incoming" + test::seed_chronological_fixture_cache \ + "$TEST_TMPDIR/incoming" "$TEST_TMPDIR/cache/exif" + test::write_album_config \ + "$config_file" "$TEST_TMPDIR/incoming" "$TEST_TMPDIR/dist" \ + 'Chronological album' 40 + # SHUFFLE (with a seed, so it would otherwise be fully deterministic too) is + # also enabled, to prove CHRONOLOGICAL_ORDER takes precedence over SHUFFLE + # when both are set (see album-photo-select.source.sh, album_photo_files). + { + printf 'CHRONOLOGICAL_ORDER=yes\n' + printf 'SHUFFLE=yes\n' + printf 'RANDOM_SEED=chronological-precedence\n' + } >> "$config_file" + + ( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" "$TEST_SHURIKEN" --generate + ) + + p |
