summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rwxr-xr-xbin/shuriken125
-rw-r--r--docs/configuration.md35
-rw-r--r--docs/generation.md12
-rw-r--r--docs/usage.md2
-rw-r--r--src/lib/action.source.sh1
-rw-r--r--src/lib/album-metadata.source.sh23
-rw-r--r--src/lib/album-photo-select.source.sh90
-rw-r--r--src/lib/bootstrap.source.sh2
-rw-r--r--src/lib/config.spec.source.sh1
-rw-r--r--src/lib/config.validate.source.sh1
-rw-r--r--src/lib/dry-run.source.sh2
-rw-r--r--src/lib/generation-metadata.source.sh3
-rw-r--r--src/shuriken.default.conf11
-rwxr-xr-xsrc/shuriken.sh2
-rwxr-xr-xtests/cli.sh196
16 files changed, 491 insertions, 23 deletions
diff --git a/README.md b/README.md
index 055e3e3..c8c71b6 100644
--- a/README.md
+++ b/README.md
@@ -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