diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-14 22:03:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-14 22:03:04 +0300 |
| commit | c08428d381b2db644a48094e105c2289ba090fb9 (patch) | |
| tree | d778795917efccd7843c40e7dc7127cf055c4f6c | |
| parent | 194354a3b42de0b59aa74ff8b5a35a1b7f8f51d6 (diff) | |
om0 fix camera slug collisions and align ISO fallback order
Review follow-up on the stats aggregation module:
- Distinct camera labels that sanitized to the same slug (e.g. two models
differing only in punctuation) shared one STATS_CAMERA_PHOTOS list and
one camera-<slug>.html link, so um0 would render a merged/ambiguous
per-camera page. Add _stats_resolve_camera_slug, which uniquifies
colliding slugs with a numeric suffix and tracks ownership in a new
STATS_SLUG_OWNERS reverse map. It returns via a nameref (not command
substitution) so the global mutation persists in the caller's shell.
- Align the stats ISO fallback order with album.source.sh's tooltip
builder (ISOSpeedRatings -> PhotographicSensitivity -> ISO) so a photo
with multiple ISO tags buckets the same value it displays.
- Document that the format breakdown trusts the file extension.
- Add test_stats_distinct_cameras_get_unique_slugs covering the collision
fix and slug reuse on re-encounter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rwxr-xr-x | bin/shuriken | 42 | ||||
| -rw-r--r-- | src/lib/stats.source.sh | 42 | ||||
| -rwxr-xr-x | tests/cli.sh | 29 |
3 files changed, 109 insertions, 4 deletions
diff --git a/bin/shuriken b/bin/shuriken index 4fd127f..7ef01fc 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -2792,6 +2792,9 @@ reset_photo_exif_stats() { declare -gA STATS_FLASH=() declare -gA STATS_TOTALS=() STATS_TOTALS[photos]=0 + # Reverse map slug -> owning camera label, used to keep camera-<slug>.html + # filenames unique when two distinct labels sanitize to the same slug. + declare -gA STATS_SLUG_OWNERS=() } # Decode an EXIF rational ("num/den") to a decimal with `scale` digits. @@ -3032,6 +3035,36 @@ _stats_camera_label() { esac } +# Resolve a unique camera-page slug for a label into the named output variable. +# A label keeps the slug it was first assigned (idempotent on re-encounter). +# Distinct labels whose sanitized slug collides (e.g. two models differing only +# in punctuation, or an all-symbol label that slugs to empty) get a numeric +# suffix so each camera maps to its own camera-<slug>.html and its own +# STATS_CAMERA_PHOTOS list. Uses a nameref output (not command substitution) so +# its mutation of STATS_SLUG_OWNERS persists in the caller's shell. +_stats_resolve_camera_slug() { + local -n slug_out_ref="$1"; shift + local -r label="$1"; shift + local base + local -i suffix=2 + + if [ -n "${STATS_CAMERA_SLUGS["$label"]:-}" ]; then + slug_out_ref="${STATS_CAMERA_SLUGS["$label"]}" + return + fi + base=$(_stats_slug "$label") + if [ -z "$base" ]; then + base=camera + fi + slug_out_ref="$base" + while [ -n "${STATS_SLUG_OWNERS["$slug_out_ref"]:-}" ] \ + && [ "${STATS_SLUG_OWNERS["$slug_out_ref"]}" != "$label" ]; do + slug_out_ref="${base}-${suffix}" + (( ++suffix )) + done + STATS_SLUG_OWNERS["$slug_out_ref"]="$label" +} + # Increment STATS_CAMERAS for a photo and record it on the per-camera list so # um0 can render camera-<slug>.html. Skips photos with no Make/Model at all. _stats_record_camera() { @@ -3044,7 +3077,7 @@ _stats_record_camera() { if [ -z "$label" ]; then return fi - slug=$(_stats_slug "$label") + _stats_resolve_camera_slug slug "$label" STATS_CAMERAS["$label"]=$(( ${STATS_CAMERAS["$label"]:-0} + 1 )) STATS_CAMERA_SLUGS["$label"]="$slug" if [ -n "${STATS_CAMERA_PHOTOS["$slug"]:-}" ]; then @@ -3102,7 +3135,10 @@ _stats_record_exposure() { _stats_bump STATS_SHUTTER "$(_stats_shutter_bucket "$decimal")" fi - raw="${values_ref[PhotographicSensitivity]:-${values_ref[ISOSpeedRatings]:-${values_ref[ISO]:-}}}" + # ISO fallback order mirrors album.source.sh's tooltip builder + # (ISOSpeedRatings -> PhotographicSensitivity -> ISO) so a photo carrying + # several ISO tags buckets the same value it shows in its tooltip. + raw="${values_ref[ISOSpeedRatings]:-${values_ref[PhotographicSensitivity]:-${values_ref[ISO]:-}}}" if [[ "$raw" =~ ^[0-9]+$ ]]; then _stats_bump STATS_ISO "$(_stats_iso_bucket "$raw")" fi @@ -3161,6 +3197,8 @@ _stats_record_dimensions() { # Record the file-format breakdown. The audit's cheapest path keys off the file # extension (no identify parsing), so this maps the extension to a format label. +# This trusts the extension over actual content: a misnamed file (e.g. a PNG +# named .jpg) is counted by its name. Unrecognized extensions fall into 'other'. _stats_record_format() { local -r photo="$1"; shift local extension="${photo##*.}" diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh index 2ac2928..cdb8668 100644 --- a/src/lib/stats.source.sh +++ b/src/lib/stats.source.sh @@ -63,6 +63,9 @@ reset_photo_exif_stats() { declare -gA STATS_FLASH=() declare -gA STATS_TOTALS=() STATS_TOTALS[photos]=0 + # Reverse map slug -> owning camera label, used to keep camera-<slug>.html + # filenames unique when two distinct labels sanitize to the same slug. + declare -gA STATS_SLUG_OWNERS=() } # Decode an EXIF rational ("num/den") to a decimal with `scale` digits. @@ -303,6 +306,36 @@ _stats_camera_label() { esac } +# Resolve a unique camera-page slug for a label into the named output variable. +# A label keeps the slug it was first assigned (idempotent on re-encounter). +# Distinct labels whose sanitized slug collides (e.g. two models differing only +# in punctuation, or an all-symbol label that slugs to empty) get a numeric +# suffix so each camera maps to its own camera-<slug>.html and its own +# STATS_CAMERA_PHOTOS list. Uses a nameref output (not command substitution) so +# its mutation of STATS_SLUG_OWNERS persists in the caller's shell. +_stats_resolve_camera_slug() { + local -n slug_out_ref="$1"; shift + local -r label="$1"; shift + local base + local -i suffix=2 + + if [ -n "${STATS_CAMERA_SLUGS["$label"]:-}" ]; then + slug_out_ref="${STATS_CAMERA_SLUGS["$label"]}" + return + fi + base=$(_stats_slug "$label") + if [ -z "$base" ]; then + base=camera + fi + slug_out_ref="$base" + while [ -n "${STATS_SLUG_OWNERS["$slug_out_ref"]:-}" ] \ + && [ "${STATS_SLUG_OWNERS["$slug_out_ref"]}" != "$label" ]; do + slug_out_ref="${base}-${suffix}" + (( ++suffix )) + done + STATS_SLUG_OWNERS["$slug_out_ref"]="$label" +} + # Increment STATS_CAMERAS for a photo and record it on the per-camera list so # um0 can render camera-<slug>.html. Skips photos with no Make/Model at all. _stats_record_camera() { @@ -315,7 +348,7 @@ _stats_record_camera() { if [ -z "$label" ]; then return fi - slug=$(_stats_slug "$label") + _stats_resolve_camera_slug slug "$label" STATS_CAMERAS["$label"]=$(( ${STATS_CAMERAS["$label"]:-0} + 1 )) STATS_CAMERA_SLUGS["$label"]="$slug" if [ -n "${STATS_CAMERA_PHOTOS["$slug"]:-}" ]; then @@ -373,7 +406,10 @@ _stats_record_exposure() { _stats_bump STATS_SHUTTER "$(_stats_shutter_bucket "$decimal")" fi - raw="${values_ref[PhotographicSensitivity]:-${values_ref[ISOSpeedRatings]:-${values_ref[ISO]:-}}}" + # ISO fallback order mirrors album.source.sh's tooltip builder + # (ISOSpeedRatings -> PhotographicSensitivity -> ISO) so a photo carrying + # several ISO tags buckets the same value it shows in its tooltip. + raw="${values_ref[ISOSpeedRatings]:-${values_ref[PhotographicSensitivity]:-${values_ref[ISO]:-}}}" if [[ "$raw" =~ ^[0-9]+$ ]]; then _stats_bump STATS_ISO "$(_stats_iso_bucket "$raw")" fi @@ -432,6 +468,8 @@ _stats_record_dimensions() { # Record the file-format breakdown. The audit's cheapest path keys off the file # extension (no identify parsing), so this maps the extension to a format label. +# This trusts the extension over actual content: a misnamed file (e.g. a PNG +# named .jpg) is counted by its name. Unrecognized extensions fall into 'other'. _stats_record_format() { local -r photo="$1"; shift local extension="${photo##*.}" diff --git a/tests/cli.sh b/tests/cli.sh index 6ae69cc..423bc49 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -4982,6 +4982,32 @@ test_stats_tolerates_missing_and_edge_case_fields() { test::teardown } +test_stats_distinct_cameras_get_unique_slugs() { + test::setup + test::source_shuriken_lib + reset_photo_exif_stats + + # Two distinct camera labels that sanitize to the same base slug must keep + # separate leaderboard entries, separate camera-<slug>.html slugs, and + # separate per-camera photo lists (regression: slug collision merged them). + accumulate_photo_stats 'a.jpg' <<< $' exif:Model: Canon EOS 1D' + accumulate_photo_stats 'b.jpg' <<< $' exif:Model: Canon EOS-1D!!' + + test "${STATS_CAMERAS[Canon EOS 1D]}" -eq 1 + test "${STATS_CAMERAS[Canon EOS-1D!!]}" -eq 1 + test "${STATS_CAMERA_SLUGS[Canon EOS 1D]}" = 'canon-eos-1d' + test "${STATS_CAMERA_SLUGS[Canon EOS-1D!!]}" = 'canon-eos-1d-2' + test "${STATS_CAMERA_PHOTOS[canon-eos-1d]}" = 'a.jpg' + test "${STATS_CAMERA_PHOTOS[canon-eos-1d-2]}" = 'b.jpg' + + # Re-encountering the first camera reuses its slug and appends, not a new one. + accumulate_photo_stats 'c.jpg' <<< $' exif:Model: Canon EOS 1D' + test "${STATS_CAMERAS[Canon EOS 1D]}" -eq 2 + test "${STATS_CAMERA_PHOTOS[canon-eos-1d]}" = $'a.jpg\nc.jpg' + + test::teardown +} + test_stats_bucket_boundaries_and_datetime_parsing() { test::setup test::source_shuriken_lib @@ -5348,6 +5374,9 @@ main() { 'stats tolerate missing and edge-case fields' \ test_stats_tolerates_missing_and_edge_case_fields test::run_case \ + 'stats give distinct cameras unique slugs' \ + test_stats_distinct_cameras_get_unique_slugs + test::run_case \ 'stats bucket boundaries and datetime parsing' \ test_stats_bucket_boundaries_and_datetime_parsing test::run_case \ |
