summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-14 22:03:04 +0300
committerPaul Buetow <paul@buetow.org>2026-06-14 22:03:04 +0300
commitc08428d381b2db644a48094e105c2289ba090fb9 (patch)
treed778795917efccd7843c40e7dc7127cf055c4f6c
parent194354a3b42de0b59aa74ff8b5a35a1b7f8f51d6 (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-xbin/shuriken42
-rw-r--r--src/lib/stats.source.sh42
-rwxr-xr-xtests/cli.sh29
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 \