summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-17 22:04:41 +0300
committerPaul Buetow <paul@buetow.org>2026-06-17 22:04:41 +0300
commit5fb1f17e3b16c8ef4b71648419251d76ae1227b2 (patch)
tree994bdb3e5fe6f291226a48c7734106459e19b291
parenteb8cbbbcb9a613757ac4440246c43ebba6758afe (diff)
en0: make stats categories self-registering via STATS_CATEGORIES
Adding an EXIF stats category previously required editing four places: the reset function, a new _stats_record_*, the body builder, and a new _stats_render_* section. Introduce a STATS_CATEGORIES registry (the single source of truth) and make the generic code iterate it instead. - STATS_CATEGORIES: ordered, pipe-delimited specs (count_array|prefix|heading|render_kind), declared -gra so it survives a function-scoped source. The array order IS the overview display order. - STATS_CATEGORY_BUCKETS: tab-delimited bucket ladders for the 'ordered' histogram kinds (apertures wide->narrow, etc.). - STATS_RECORD_FUNCTIONS: the per-photo recorder dispatch list. Collapsed touch-points: - reset_photo_exif_stats clears each registry count array via _stats_category_arrays. - accumulate_photo_stats dispatches recorders from STATS_RECORD_FUNCTIONS. - _stats_build_body iterates STATS_CATEGORIES, dispatching each spec through _stats_render_category (camera/ranked/ordered/month kinds). - _stats_render_ordered_section reads its ladder from STATS_CATEGORY_BUCKETS; the camera leaderboard is now ranked + a 'stats-leaderboard' list_class. Adding a category is now: append one STATS_CATEGORIES entry (plus a STATS_CATEGORY_BUCKETS row for an ordered ladder) and have a record function tally into its array. No edits to reset, the body builder, or a per-category render branch. Behaviour-preserving: category order, bucket order, headings, counts and links are unchanged. Verified byte-identical by diffing the stats/ output of the pre-change binary against the new one over the same fixture album. Added test_stats_categories_registry_is_single_source_of_truth (registered in main()) asserting reset, the body builder and the bucket ladders all derive from the registry; it fails if a category is added in only one place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken295
-rw-r--r--src/lib/stats-aggregate.source.sh132
-rw-r--r--src/lib/stats-render.source.sh167
-rwxr-xr-xtests/cli.sh81
4 files changed, 443 insertions, 232 deletions
diff --git a/bin/shuriken b/bin/shuriken
index 86317e3..cfe2ec2 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -3254,26 +3254,110 @@ refresh_splash() {
# (sparse data) and only render a section when it has entries. STATS_TOTALS
# gives the denominator for percentages.
+# ----------------------------------------------------------------------------
+# Category registry (single source of truth, task en0)
+# ----------------------------------------------------------------------------
+# STATS_CATEGORIES is the one place a stats category is defined. Each entry is a
+# '|'-delimited spec (same encoding as template.source.sh's
+# TEMPLATE_RENDER_FIELD_SPECS) holding everything the generic reset, recording
+# dispatch and render loops need:
+#
+# count_array|prefix|heading|render_kind
+#
+# count_array the STATS_* associative array holding this category's counts;
+# also the array reset_photo_exif_stats clears each run.
+# prefix the namespace passed to _stats_tally / _stats_filter_link (and
+# the filter-page slug prefix, e.g. "iso" -> iso-400).
+# heading the <h2> shown on the overview (and the per-section title).
+# render_kind how the overview renders this category's bars:
+# camera - count-desc leaderboard (extra 'stats-leaderboard'
+# <ul> class); only the camera category uses it.
+# ranked - count-desc bars (years, lenses, decoded enums).
+# ordered - fixed bucket-ladder order from
+# STATS_CATEGORY_BUCKETS (apertures wide->narrow, ...).
+# month - calendar Jan..Dec order with English month names.
+#
+# The array order IS the overview/body display order, so it must reproduce the
+# historical _stats_build_body sequence exactly (camera, year, month, the four
+# exposure histograms, the dimension+format histograms, then the enum/lens
+# ranked sections). The per-category record functions stay grouped by EXIF
+# source affinity (one datetime parser fills year+month, one exposure parser
+# fills four histograms) and are dispatched from STATS_RECORD_FUNCTIONS below;
+# they tally into the array named here. Adding a category now means: append one
+# STATS_CATEGORIES entry (+ a STATS_CATEGORY_BUCKETS row for an ordered ladder)
+# and make some record function tally into its array -- no edits to the reset,
+# the body builder, or a per-category render branch.
+# Declared -g so it survives being sourced from inside a function (the test
+# harness sources the lib via test::source_shuriken_lib); a plain `declare -r`
+# would be function-local and vanish on return.
+declare -gra STATS_CATEGORIES=(
+ 'STATS_CAMERAS|camera|Camera leaderboard|camera'
+ 'STATS_YEARS|year|Photos per year|ranked'
+ 'STATS_MONTHS|month|Photos per month|month'
+ 'STATS_APERTURE|aperture|Aperture|ordered'
+ 'STATS_SHUTTER|shutter|Shutter speed|ordered'
+ 'STATS_ISO|iso|ISO|ordered'
+ 'STATS_FOCAL|focal|Focal length|ordered'
+ 'STATS_MEGAPIXELS|megapixels|Megapixels|ordered'
+ 'STATS_ASPECT|aspect|Aspect ratio|ordered'
+ 'STATS_ORIENTATION|orientation|Orientation|ordered'
+ 'STATS_FORMAT|format|File format|ordered'
+ 'STATS_LENSES|lens|Lenses|ranked'
+ 'STATS_EXPOSURE_PROGRAM|exposure-program|Exposure program|ranked'
+ 'STATS_METERING|metering|Metering mode|ranked'
+ 'STATS_WHITE_BALANCE|white-balance|White balance|ranked'
+ 'STATS_FLASH|flash|Flash|ranked'
+)
+
+# Bucket ladders for the 'ordered' categories, keyed by their count_array name.
+# Tab-delimited because the bucket labels themselves contain spaces and slashes
+# (but never tabs). These reproduce the photographer-friendly axis order the
+# aggregator's *_bucket helpers emit, so the histogram axes read naturally
+# regardless of how many photos landed in each bucket.
+declare -gA STATS_CATEGORY_BUCKETS=(
+ [STATS_APERTURE]=$'f/1.8 or wider\tf/2\tf/2.8\tf/4\tf/5.6\tf/8\tf/11\tf/16\tf/22 or narrower'
+ [STATS_SHUTTER]=$'1/4000s or faster\t1/2000s\t1/1000s\t1/500s\t1/250s\t1/125s\t1/60s\t1/30s\t1/15s\t1/8s\t1/4s\t1/2s\t1s\tlonger than 1s'
+ [STATS_ISO]=$'50\t100\t200\t400\t800\t1600\t3200\t6400\t12800\t25600\tover 25600'
+ [STATS_FOCAL]=$'under 24mm\t24-35mm\t35-70mm\t70-135mm\t135-200mm\tover 200mm'
+ [STATS_MEGAPIXELS]=$'under 2MP\t2-5MP\t5-10MP\t10-20MP\t20-40MP\t40-80MP\tover 80MP'
+ [STATS_ASPECT]=$'3:2\t4:3\t16:9\t1:1\t5:4\tother'
+ [STATS_ORIENTATION]=$'Landscape\tPortrait\tSquare'
+ [STATS_FORMAT]=$'JPEG\tPNG\tWEBP\tGIF\tother'
+)
+
+# The per-photo record functions, dispatched in order by accumulate_photo_stats.
+# Each is grouped by EXIF source affinity (so one identify parse fills several
+# related categories) and tallies into the STATS_* arrays named in
+# STATS_CATEGORIES. _stats_record_format takes only the photo path (no EXIF
+# values), so accumulate_photo_stats special-cases it; the rest take the parsed
+# values array plus the photo path.
+# -g so the dispatch list survives a function-scoped source (see STATS_CATEGORIES).
+declare -gra STATS_RECORD_FUNCTIONS=(
+ _stats_record_camera
+ _stats_record_datetime
+ _stats_record_exposure
+ _stats_record_enums
+ _stats_record_dimensions
+)
+
+# Print the count_array name for each registry entry, in display order. Used by
+# the generic reset and any consumer that needs to walk every category's array.
+_stats_category_arrays() {
+ local spec
+ for spec in "${STATS_CATEGORIES[@]}"; do
+ printf '%s\n' "${spec%%|*}"
+ done
+}
+
# Reset every stats global to an empty associative array. Called at the start of
# collect_photo_exif_stats so repeated invocations (e.g. tests, --refresh) do
-# not accumulate stale counts.
+# not accumulate stale counts. The per-category count arrays are cleared by
+# iterating STATS_CATEGORIES so a new category needs no edit here.
reset_photo_exif_stats() {
- declare -gA STATS_CAMERAS=()
- declare -gA STATS_LENSES=()
- declare -gA STATS_YEARS=()
- declare -gA STATS_MONTHS=()
- declare -gA STATS_APERTURE=()
- declare -gA STATS_SHUTTER=()
- declare -gA STATS_ISO=()
- declare -gA STATS_FOCAL=()
- declare -gA STATS_MEGAPIXELS=()
- declare -gA STATS_ASPECT=()
- declare -gA STATS_ORIENTATION=()
- declare -gA STATS_FORMAT=()
- declare -gA STATS_EXPOSURE_PROGRAM=()
- declare -gA STATS_METERING=()
- declare -gA STATS_WHITE_BALANCE=()
- declare -gA STATS_FLASH=()
+ local array_name
+ while IFS= read -r array_name; do
+ declare -gA "$array_name=()"
+ done < <(_stats_category_arrays)
declare -gA STATS_TOTALS=()
STATS_TOTALS[photos]=0
# Every tallied bucket (across all categories) becomes a clickable filter
@@ -3772,14 +3856,18 @@ accumulate_photo_stats() {
# exif_values is filled and read through the nameref helpers below.
# shellcheck disable=SC2034
local -A exif_values=()
+ local record_fn
_stats_parse_identify_stream exif_values
STATS_TOTALS[photos]=$(( STATS_TOTALS[photos] + 1 ))
- _stats_record_camera exif_values "$photo"
- _stats_record_datetime exif_values "$photo"
- _stats_record_exposure exif_values "$photo"
- _stats_record_enums exif_values "$photo"
- _stats_record_dimensions exif_values "$photo"
+ # Dispatch the EXIF-driven recorders from the registry list so categories are
+ # not hardcoded here. Each takes the parsed values array plus the photo path.
+ for record_fn in "${STATS_RECORD_FUNCTIONS[@]}"; do
+ "$record_fn" exif_values "$photo"
+ done
+ # _stats_record_format keys off the file extension only (no EXIF parse), so
+ # it runs separately with just the photo path -- kept last to preserve the
+ # historical tally order.
_stats_record_format "$photo"
}
@@ -3915,27 +4003,6 @@ _stats_filter_link() {
fi
}
-# Render the camera leaderboard: one bar per camera, sorted by count descending,
-# each label linking to its camera mini-album. Camera labels come from EXIF, so
-# the text is HTML-escaped. Skipped entirely when no camera data was collected.
-_stats_render_camera_section() {
- local -ri total="$1"; shift
- local label
- local row_html
- local -i max
-
- if (( ${#STATS_CAMERAS[@]} == 0 )); then
- return
- fi
- max=$(_stats_max_count STATS_CAMERAS)
- _stats_section_open 'Camera leaderboard' 'stats-leaderboard'
- while IFS= read -r label; do
- row_html=$(_stats_filter_link camera "$label" "$(_html_escape "$label")")
- _stats_bar_row "$row_html" "${STATS_CAMERAS[$label]}" "$total" "$max"
- done < <(_stats_keys_by_count_desc STATS_CAMERAS)
- _stats_section_close
-}
-
# Print an array's keys ordered by descending count (ties broken by key) so the
# busiest bucket leads. Used for the leaderboard and other count-ranked sections.
# LC_ALL=C pins the tie-break collation so the generated page is byte-identical
@@ -3950,9 +4017,11 @@ _stats_keys_by_count_desc() {
}
# Render a histogram section using an explicit bucket order (e.g. apertures from
-# wide to narrow) rather than count ranking, so the axis reads naturally. Only
-# buckets that actually occurred are emitted, and the whole section is skipped
-# when none did. Bucket labels are internal/trusted but still escaped for safety.
+# wide to narrow) rather than count ranking, so the axis reads naturally. The
+# bucket ladder is read from STATS_CATEGORY_BUCKETS[array_name] (tab-delimited).
+# Only buckets that actually occurred are emitted, and the whole section is
+# skipped when none did. Bucket labels are internal/trusted but still escaped for
+# safety.
_stats_render_ordered_section() {
local -r heading="$1"; shift
local -r array_name="$1"; shift
@@ -3961,14 +4030,16 @@ _stats_render_ordered_section() {
local -n counts_ref="$array_name"
local bucket
local row_html
+ local -a buckets=()
local -i max
if (( ${#counts_ref[@]} == 0 )); then
return
fi
+ IFS=$'\t' read -r -a buckets <<< "${STATS_CATEGORY_BUCKETS[$array_name]}"
max=$(_stats_max_count "$array_name")
_stats_section_open "$heading"
- for bucket in "$@"; do
+ for bucket in "${buckets[@]}"; do
if [ -z "${counts_ref[$bucket]:-}" ]; then
continue
fi
@@ -3978,13 +4049,16 @@ _stats_render_ordered_section() {
_stats_section_close
}
-# Render a section ranked by count (cameras aside). Used where there is no
-# natural axis order: years, lenses, and the decoded enum categories.
+# Render a section ranked by count. Used where there is no natural axis order:
+# the camera leaderboard, years, lenses, and the decoded enum categories. An
+# optional list_class adds an extra CSS class to the <ul> (the camera leaderboard
+# passes 'stats-leaderboard' to space out its long, wrapping camera names).
_stats_render_ranked_section() {
local -r heading="$1"; shift
local -r array_name="$1"; shift
local -r prefix="$1"; shift
local -ri total="$1"; shift
+ local -r list_class="${1:-}"
local -n counts_ref="$array_name"
local key
local row_html
@@ -3994,7 +4068,7 @@ _stats_render_ranked_section() {
return
fi
max=$(_stats_max_count "$array_name")
- _stats_section_open "$heading"
+ _stats_section_open "$heading" "$list_class"
while IFS= read -r key; do
row_html=$(_stats_filter_link "$prefix" "$key" "$(_html_escape "$key")")
_stats_bar_row "$row_html" "${counts_ref[$key]}" "$total" "$max"
@@ -4002,20 +4076,17 @@ _stats_render_ranked_section() {
_stats_section_close
}
-# Render the temporal sections. Years rank by count; months walk Jan..Dec in
-# calendar order using human month names for the labels.
-_stats_render_temporal_sections() {
- local -ri total="$1"; shift
-
- _stats_render_ranked_section 'Photos per year' STATS_YEARS year "$total"
- _stats_render_month_section "$total"
-}
-
# Render the per-month histogram in calendar order. The aggregator keys months
# by zero-padded number (01..12); this maps each to its English name so the axis
# is readable, and reuses the ordered-section omit-when-empty behaviour inline.
+# Signature matches the other render kinds (heading array_name prefix total) so
+# the registry dispatcher can call it uniformly.
_stats_render_month_section() {
+ local -r heading="$1"; shift
+ local -r array_name="$1"; shift
+ local -r prefix="$1"; shift
local -ri total="$1"; shift
+ local -n counts_ref="$array_name"
local -ra month_names=(
'' January February March April May June July August
September October November December )
@@ -4023,82 +4094,76 @@ _stats_render_month_section() {
local key
local -i max
- if (( ${#STATS_MONTHS[@]} == 0 )); then
+ if (( ${#counts_ref[@]} == 0 )); then
return
fi
- max=$(_stats_max_count STATS_MONTHS)
- _stats_section_open 'Photos per month'
+ max=$(_stats_max_count "$array_name")
+ _stats_section_open "$heading"
for (( month = 1; month <= 12; month++ )); do
key=$(printf '%02d' "$month")
- if [ -z "${STATS_MONTHS[$key]:-}" ]; then
+ if [ -z "${counts_ref[$key]:-}" ]; then
continue
fi
_stats_bar_row \
- "$(_stats_filter_link month "$key" "${month_names[$month]}")" \
- "${STATS_MONTHS[$key]}" "$total" "$max"
+ "$(_stats_filter_link "$prefix" "$key" "${month_names[$month]}")" \
+ "${counts_ref[$key]}" "$total" "$max"
done
_stats_section_close
}
-# Render the exposure histograms in photographer-friendly axis order (the same
-# bucket ladders the aggregator's *_bucket helpers produce).
-_stats_render_exposure_sections() {
+# Render one category from its registry spec. Dispatches on the spec's
+# render_kind to the matching section renderer, all of which self-skip when their
+# array is empty. This is the single per-category render path: a new category
+# just needs a STATS_CATEGORIES entry (no new render branch here unless it needs
+# a brand-new kind).
+_stats_render_category() {
+ local -r spec="$1"; shift
local -ri total="$1"; shift
+ local -a fields=()
- _stats_render_ordered_section 'Aperture' STATS_APERTURE aperture "$total" \
- 'f/1.8 or wider' 'f/2' 'f/2.8' 'f/4' 'f/5.6' 'f/8' 'f/11' 'f/16' \
- 'f/22 or narrower'
- _stats_render_ordered_section 'Shutter speed' STATS_SHUTTER shutter "$total" \
- '1/4000s or faster' '1/2000s' '1/1000s' '1/500s' '1/250s' '1/125s' \
- '1/60s' '1/30s' '1/15s' '1/8s' '1/4s' '1/2s' '1s' 'longer than 1s'
- _stats_render_ordered_section 'ISO' STATS_ISO iso "$total" \
- '50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \
- 'over 25600'
- _stats_render_ordered_section 'Focal length' STATS_FOCAL focal "$total" \
- 'under 24mm' '24-35mm' '35-70mm' '70-135mm' '135-200mm' 'over 200mm'
-}
-
-# Render the dimension histograms (megapixels, aspect ratio, orientation) and
-# the file-format breakdown, each in its natural axis order.
-_stats_render_dimension_sections() {
- local -ri total="$1"; shift
-
- _stats_render_ordered_section 'Megapixels' STATS_MEGAPIXELS megapixels \
- "$total" \
- 'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP'
- _stats_render_ordered_section 'Aspect ratio' STATS_ASPECT aspect "$total" \
- '3:2' '4:3' '16:9' '1:1' '5:4' 'other'
- _stats_render_ordered_section 'Orientation' STATS_ORIENTATION orientation \
- "$total" 'Landscape' 'Portrait' 'Square'
- _stats_render_ordered_section 'File format' STATS_FORMAT format "$total" \
- 'JPEG' 'PNG' 'WEBP' 'GIF' 'other'
-}
-
-# Render the decoded enum sections and the (sparse) lens leaderboard. All rank by
-# count and self-skip when empty, so absent tags simply omit their section.
-_stats_render_enum_sections() {
- local -ri total="$1"; shift
+ IFS='|' read -r -a fields <<< "$spec"
+ local -r array_name="${fields[0]}"
+ local -r prefix="${fields[1]}"
+ local -r heading="${fields[2]}"
+ local -r render_kind="${fields[3]}"
- _stats_render_ranked_section 'Lenses' STATS_LENSES lens "$total"
- _stats_render_ranked_section 'Exposure program' \
- STATS_EXPOSURE_PROGRAM exposure-program "$total"
- _stats_render_ranked_section 'Metering mode' STATS_METERING metering "$total"
- _stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE \
- white-balance "$total"
- _stats_render_ranked_section 'Flash' STATS_FLASH flash "$total"
+ case "$render_kind" in
+ camera)
+ _stats_render_ranked_section "$heading" "$array_name" "$prefix" \
+ "$total" stats-leaderboard
+ ;;
+ ranked)
+ _stats_render_ranked_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ ordered)
+ _stats_render_ordered_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ month)
+ _stats_render_month_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ *)
+ printf 'ERROR: unknown stats render kind %q for %s\n' \
+ "$render_kind" "$array_name" >&2
+ return 1
+ ;;
+ esac
}
-# Assemble the full stats body from every section in display order. Returns the
-# HTML on stdout; render_stats_page captures it into the stats_body context var.
+# Assemble the full stats body by iterating STATS_CATEGORIES in registry order
+# (which IS the display order). Returns the HTML on stdout; render_stats_page
+# captures it into the stats_body context var. Adding a category appends one
+# registry entry -- no edit here.
_stats_build_body() {
local -ri total="${STATS_TOTALS[photos]:-0}"
+ local spec
printf '<p class="stats-total">%d photos analysed.</p>\n' "$total"
- _stats_render_camera_section "$total"
- _stats_render_temporal_sections "$total"
- _stats_render_exposure_sections "$total"
- _stats_render_dimension_sections "$total"
- _stats_render_enum_sections "$total"
+ for spec in "${STATS_CATEGORIES[@]}"; do
+ _stats_render_category "$spec" "$total"
+ done
}
# Public render entry point (handoff for task rm0). Builds the body from the
diff --git a/src/lib/stats-aggregate.source.sh b/src/lib/stats-aggregate.source.sh
index 46592c4..af047c2 100644
--- a/src/lib/stats-aggregate.source.sh
+++ b/src/lib/stats-aggregate.source.sh
@@ -42,26 +42,110 @@
# (sparse data) and only render a section when it has entries. STATS_TOTALS
# gives the denominator for percentages.
+# ----------------------------------------------------------------------------
+# Category registry (single source of truth, task en0)
+# ----------------------------------------------------------------------------
+# STATS_CATEGORIES is the one place a stats category is defined. Each entry is a
+# '|'-delimited spec (same encoding as template.source.sh's
+# TEMPLATE_RENDER_FIELD_SPECS) holding everything the generic reset, recording
+# dispatch and render loops need:
+#
+# count_array|prefix|heading|render_kind
+#
+# count_array the STATS_* associative array holding this category's counts;
+# also the array reset_photo_exif_stats clears each run.
+# prefix the namespace passed to _stats_tally / _stats_filter_link (and
+# the filter-page slug prefix, e.g. "iso" -> iso-400).
+# heading the <h2> shown on the overview (and the per-section title).
+# render_kind how the overview renders this category's bars:
+# camera - count-desc leaderboard (extra 'stats-leaderboard'
+# <ul> class); only the camera category uses it.
+# ranked - count-desc bars (years, lenses, decoded enums).
+# ordered - fixed bucket-ladder order from
+# STATS_CATEGORY_BUCKETS (apertures wide->narrow, ...).
+# month - calendar Jan..Dec order with English month names.
+#
+# The array order IS the overview/body display order, so it must reproduce the
+# historical _stats_build_body sequence exactly (camera, year, month, the four
+# exposure histograms, the dimension+format histograms, then the enum/lens
+# ranked sections). The per-category record functions stay grouped by EXIF
+# source affinity (one datetime parser fills year+month, one exposure parser
+# fills four histograms) and are dispatched from STATS_RECORD_FUNCTIONS below;
+# they tally into the array named here. Adding a category now means: append one
+# STATS_CATEGORIES entry (+ a STATS_CATEGORY_BUCKETS row for an ordered ladder)
+# and make some record function tally into its array -- no edits to the reset,
+# the body builder, or a per-category render branch.
+# Declared -g so it survives being sourced from inside a function (the test
+# harness sources the lib via test::source_shuriken_lib); a plain `declare -r`
+# would be function-local and vanish on return.
+declare -gra STATS_CATEGORIES=(
+ 'STATS_CAMERAS|camera|Camera leaderboard|camera'
+ 'STATS_YEARS|year|Photos per year|ranked'
+ 'STATS_MONTHS|month|Photos per month|month'
+ 'STATS_APERTURE|aperture|Aperture|ordered'
+ 'STATS_SHUTTER|shutter|Shutter speed|ordered'
+ 'STATS_ISO|iso|ISO|ordered'
+ 'STATS_FOCAL|focal|Focal length|ordered'
+ 'STATS_MEGAPIXELS|megapixels|Megapixels|ordered'
+ 'STATS_ASPECT|aspect|Aspect ratio|ordered'
+ 'STATS_ORIENTATION|orientation|Orientation|ordered'
+ 'STATS_FORMAT|format|File format|ordered'
+ 'STATS_LENSES|lens|Lenses|ranked'
+ 'STATS_EXPOSURE_PROGRAM|exposure-program|Exposure program|ranked'
+ 'STATS_METERING|metering|Metering mode|ranked'
+ 'STATS_WHITE_BALANCE|white-balance|White balance|ranked'
+ 'STATS_FLASH|flash|Flash|ranked'
+)
+
+# Bucket ladders for the 'ordered' categories, keyed by their count_array name.
+# Tab-delimited because the bucket labels themselves contain spaces and slashes
+# (but never tabs). These reproduce the photographer-friendly axis order the
+# aggregator's *_bucket helpers emit, so the histogram axes read naturally
+# regardless of how many photos landed in each bucket.
+declare -gA STATS_CATEGORY_BUCKETS=(
+ [STATS_APERTURE]=$'f/1.8 or wider\tf/2\tf/2.8\tf/4\tf/5.6\tf/8\tf/11\tf/16\tf/22 or narrower'
+ [STATS_SHUTTER]=$'1/4000s or faster\t1/2000s\t1/1000s\t1/500s\t1/250s\t1/125s\t1/60s\t1/30s\t1/15s\t1/8s\t1/4s\t1/2s\t1s\tlonger than 1s'
+ [STATS_ISO]=$'50\t100\t200\t400\t800\t1600\t3200\t6400\t12800\t25600\tover 25600'
+ [STATS_FOCAL]=$'under 24mm\t24-35mm\t35-70mm\t70-135mm\t135-200mm\tover 200mm'
+ [STATS_MEGAPIXELS]=$'under 2MP\t2-5MP\t5-10MP\t10-20MP\t20-40MP\t40-80MP\tover 80MP'
+ [STATS_ASPECT]=$'3:2\t4:3\t16:9\t1:1\t5:4\tother'
+ [STATS_ORIENTATION]=$'Landscape\tPortrait\tSquare'
+ [STATS_FORMAT]=$'JPEG\tPNG\tWEBP\tGIF\tother'
+)
+
+# The per-photo record functions, dispatched in order by accumulate_photo_stats.
+# Each is grouped by EXIF source affinity (so one identify parse fills several
+# related categories) and tallies into the STATS_* arrays named in
+# STATS_CATEGORIES. _stats_record_format takes only the photo path (no EXIF
+# values), so accumulate_photo_stats special-cases it; the rest take the parsed
+# values array plus the photo path.
+# -g so the dispatch list survives a function-scoped source (see STATS_CATEGORIES).
+declare -gra STATS_RECORD_FUNCTIONS=(
+ _stats_record_camera
+ _stats_record_datetime
+ _stats_record_exposure
+ _stats_record_enums
+ _stats_record_dimensions
+)
+
+# Print the count_array name for each registry entry, in display order. Used by
+# the generic reset and any consumer that needs to walk every category's array.
+_stats_category_arrays() {
+ local spec
+ for spec in "${STATS_CATEGORIES[@]}"; do
+ printf '%s\n' "${spec%%|*}"
+ done
+}
+
# Reset every stats global to an empty associative array. Called at the start of
# collect_photo_exif_stats so repeated invocations (e.g. tests, --refresh) do
-# not accumulate stale counts.
+# not accumulate stale counts. The per-category count arrays are cleared by
+# iterating STATS_CATEGORIES so a new category needs no edit here.
reset_photo_exif_stats() {
- declare -gA STATS_CAMERAS=()
- declare -gA STATS_LENSES=()
- declare -gA STATS_YEARS=()
- declare -gA STATS_MONTHS=()
- declare -gA STATS_APERTURE=()
- declare -gA STATS_SHUTTER=()
- declare -gA STATS_ISO=()
- declare -gA STATS_FOCAL=()
- declare -gA STATS_MEGAPIXELS=()
- declare -gA STATS_ASPECT=()
- declare -gA STATS_ORIENTATION=()
- declare -gA STATS_FORMAT=()
- declare -gA STATS_EXPOSURE_PROGRAM=()
- declare -gA STATS_METERING=()
- declare -gA STATS_WHITE_BALANCE=()
- declare -gA STATS_FLASH=()
+ local array_name
+ while IFS= read -r array_name; do
+ declare -gA "$array_name=()"
+ done < <(_stats_category_arrays)
declare -gA STATS_TOTALS=()
STATS_TOTALS[photos]=0
# Every tallied bucket (across all categories) becomes a clickable filter
@@ -560,14 +644,18 @@ accumulate_photo_stats() {
# exif_values is filled and read through the nameref helpers below.
# shellcheck disable=SC2034
local -A exif_values=()
+ local record_fn
_stats_parse_identify_stream exif_values
STATS_TOTALS[photos]=$(( STATS_TOTALS[photos] + 1 ))
- _stats_record_camera exif_values "$photo"
- _stats_record_datetime exif_values "$photo"
- _stats_record_exposure exif_values "$photo"
- _stats_record_enums exif_values "$photo"
- _stats_record_dimensions exif_values "$photo"
+ # Dispatch the EXIF-driven recorders from the registry list so categories are
+ # not hardcoded here. Each takes the parsed values array plus the photo path.
+ for record_fn in "${STATS_RECORD_FUNCTIONS[@]}"; do
+ "$record_fn" exif_values "$photo"
+ done
+ # _stats_record_format keys off the file extension only (no EXIF parse), so
+ # it runs separately with just the photo path -- kept last to preserve the
+ # historical tally order.
_stats_record_format "$photo"
}
diff --git a/src/lib/stats-render.source.sh b/src/lib/stats-render.source.sh
index 66ff6b4..b13a3ae 100644
--- a/src/lib/stats-render.source.sh
+++ b/src/lib/stats-render.source.sh
@@ -116,27 +116,6 @@ _stats_filter_link() {
fi
}
-# Render the camera leaderboard: one bar per camera, sorted by count descending,
-# each label linking to its camera mini-album. Camera labels come from EXIF, so
-# the text is HTML-escaped. Skipped entirely when no camera data was collected.
-_stats_render_camera_section() {
- local -ri total="$1"; shift
- local label
- local row_html
- local -i max
-
- if (( ${#STATS_CAMERAS[@]} == 0 )); then
- return
- fi
- max=$(_stats_max_count STATS_CAMERAS)
- _stats_section_open 'Camera leaderboard' 'stats-leaderboard'
- while IFS= read -r label; do
- row_html=$(_stats_filter_link camera "$label" "$(_html_escape "$label")")
- _stats_bar_row "$row_html" "${STATS_CAMERAS[$label]}" "$total" "$max"
- done < <(_stats_keys_by_count_desc STATS_CAMERAS)
- _stats_section_close
-}
-
# Print an array's keys ordered by descending count (ties broken by key) so the
# busiest bucket leads. Used for the leaderboard and other count-ranked sections.
# LC_ALL=C pins the tie-break collation so the generated page is byte-identical
@@ -151,9 +130,11 @@ _stats_keys_by_count_desc() {
}
# Render a histogram section using an explicit bucket order (e.g. apertures from
-# wide to narrow) rather than count ranking, so the axis reads naturally. Only
-# buckets that actually occurred are emitted, and the whole section is skipped
-# when none did. Bucket labels are internal/trusted but still escaped for safety.
+# wide to narrow) rather than count ranking, so the axis reads naturally. The
+# bucket ladder is read from STATS_CATEGORY_BUCKETS[array_name] (tab-delimited).
+# Only buckets that actually occurred are emitted, and the whole section is
+# skipped when none did. Bucket labels are internal/trusted but still escaped for
+# safety.
_stats_render_ordered_section() {
local -r heading="$1"; shift
local -r array_name="$1"; shift
@@ -162,14 +143,16 @@ _stats_render_ordered_section() {
local -n counts_ref="$array_name"
local bucket
local row_html
+ local -a buckets=()
local -i max
if (( ${#counts_ref[@]} == 0 )); then
return
fi
+ IFS=$'\t' read -r -a buckets <<< "${STATS_CATEGORY_BUCKETS[$array_name]}"
max=$(_stats_max_count "$array_name")
_stats_section_open "$heading"
- for bucket in "$@"; do
+ for bucket in "${buckets[@]}"; do
if [ -z "${counts_ref[$bucket]:-}" ]; then
continue
fi
@@ -179,13 +162,16 @@ _stats_render_ordered_section() {
_stats_section_close
}
-# Render a section ranked by count (cameras aside). Used where there is no
-# natural axis order: years, lenses, and the decoded enum categories.
+# Render a section ranked by count. Used where there is no natural axis order:
+# the camera leaderboard, years, lenses, and the decoded enum categories. An
+# optional list_class adds an extra CSS class to the <ul> (the camera leaderboard
+# passes 'stats-leaderboard' to space out its long, wrapping camera names).
_stats_render_ranked_section() {
local -r heading="$1"; shift
local -r array_name="$1"; shift
local -r prefix="$1"; shift
local -ri total="$1"; shift
+ local -r list_class="${1:-}"
local -n counts_ref="$array_name"
local key
local row_html
@@ -195,7 +181,7 @@ _stats_render_ranked_section() {
return
fi
max=$(_stats_max_count "$array_name")
- _stats_section_open "$heading"
+ _stats_section_open "$heading" "$list_class"
while IFS= read -r key; do
row_html=$(_stats_filter_link "$prefix" "$key" "$(_html_escape "$key")")
_stats_bar_row "$row_html" "${counts_ref[$key]}" "$total" "$max"
@@ -203,20 +189,17 @@ _stats_render_ranked_section() {
_stats_section_close
}
-# Render the temporal sections. Years rank by count; months walk Jan..Dec in
-# calendar order using human month names for the labels.
-_stats_render_temporal_sections() {
- local -ri total="$1"; shift
-
- _stats_render_ranked_section 'Photos per year' STATS_YEARS year "$total"
- _stats_render_month_section "$total"
-}
-
# Render the per-month histogram in calendar order. The aggregator keys months
# by zero-padded number (01..12); this maps each to its English name so the axis
# is readable, and reuses the ordered-section omit-when-empty behaviour inline.
+# Signature matches the other render kinds (heading array_name prefix total) so
+# the registry dispatcher can call it uniformly.
_stats_render_month_section() {
+ local -r heading="$1"; shift
+ local -r array_name="$1"; shift
+ local -r prefix="$1"; shift
local -ri total="$1"; shift
+ local -n counts_ref="$array_name"
local -ra month_names=(
'' January February March April May June July August
September October November December )
@@ -224,82 +207,76 @@ _stats_render_month_section() {
local key
local -i max
- if (( ${#STATS_MONTHS[@]} == 0 )); then
+ if (( ${#counts_ref[@]} == 0 )); then
return
fi
- max=$(_stats_max_count STATS_MONTHS)
- _stats_section_open 'Photos per month'
+ max=$(_stats_max_count "$array_name")
+ _stats_section_open "$heading"
for (( month = 1; month <= 12; month++ )); do
key=$(printf '%02d' "$month")
- if [ -z "${STATS_MONTHS[$key]:-}" ]; then
+ if [ -z "${counts_ref[$key]:-}" ]; then
continue
fi
_stats_bar_row \
- "$(_stats_filter_link month "$key" "${month_names[$month]}")" \
- "${STATS_MONTHS[$key]}" "$total" "$max"
+ "$(_stats_filter_link "$prefix" "$key" "${month_names[$month]}")" \
+ "${counts_ref[$key]}" "$total" "$max"
done
_stats_section_close
}
-# Render the exposure histograms in photographer-friendly axis order (the same
-# bucket ladders the aggregator's *_bucket helpers produce).
-_stats_render_exposure_sections() {
- local -ri total="$1"; shift
-
- _stats_render_ordered_section 'Aperture' STATS_APERTURE aperture "$total" \
- 'f/1.8 or wider' 'f/2' 'f/2.8' 'f/4' 'f/5.6' 'f/8' 'f/11' 'f/16' \
- 'f/22 or narrower'
- _stats_render_ordered_section 'Shutter speed' STATS_SHUTTER shutter "$total" \
- '1/4000s or faster' '1/2000s' '1/1000s' '1/500s' '1/250s' '1/125s' \
- '1/60s' '1/30s' '1/15s' '1/8s' '1/4s' '1/2s' '1s' 'longer than 1s'
- _stats_render_ordered_section 'ISO' STATS_ISO iso "$total" \
- '50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \
- 'over 25600'
- _stats_render_ordered_section 'Focal length' STATS_FOCAL focal "$total" \
- 'under 24mm' '24-35mm' '35-70mm' '70-135mm' '135-200mm' 'over 200mm'
-}
-
-# Render the dimension histograms (megapixels, aspect ratio, orientation) and
-# the file-format breakdown, each in its natural axis order.
-_stats_render_dimension_sections() {
- local -ri total="$1"; shift
-
- _stats_render_ordered_section 'Megapixels' STATS_MEGAPIXELS megapixels \
- "$total" \
- 'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP'
- _stats_render_ordered_section 'Aspect ratio' STATS_ASPECT aspect "$total" \
- '3:2' '4:3' '16:9' '1:1' '5:4' 'other'
- _stats_render_ordered_section 'Orientation' STATS_ORIENTATION orientation \
- "$total" 'Landscape' 'Portrait' 'Square'
- _stats_render_ordered_section 'File format' STATS_FORMAT format "$total" \
- 'JPEG' 'PNG' 'WEBP' 'GIF' 'other'
-}
-
-# Render the decoded enum sections and the (sparse) lens leaderboard. All rank by
-# count and self-skip when empty, so absent tags simply omit their section.
-_stats_render_enum_sections() {
+# Render one category from its registry spec. Dispatches on the spec's
+# render_kind to the matching section renderer, all of which self-skip when their
+# array is empty. This is the single per-category render path: a new category
+# just needs a STATS_CATEGORIES entry (no new render branch here unless it needs
+# a brand-new kind).
+_stats_render_category() {
+ local -r spec="$1"; shift
local -ri total="$1"; shift
-
- _stats_render_ranked_section 'Lenses' STATS_LENSES lens "$total"
- _stats_render_ranked_section 'Exposure program' \
- STATS_EXPOSURE_PROGRAM exposure-program "$total"
- _stats_render_ranked_section 'Metering mode' STATS_METERING metering "$total"
- _stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE \
- white-balance "$total"
- _stats_render_ranked_section 'Flash' STATS_FLASH flash "$total"
+ local -a fields=()
+
+ IFS='|' read -r -a fields <<< "$spec"
+ local -r array_name="${fields[0]}"
+ local -r prefix="${fields[1]}"
+ local -r heading="${fields[2]}"
+ local -r render_kind="${fields[3]}"
+
+ case "$render_kind" in
+ camera)
+ _stats_render_ranked_section "$heading" "$array_name" "$prefix" \
+ "$total" stats-leaderboard
+ ;;
+ ranked)
+ _stats_render_ranked_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ ordered)
+ _stats_render_ordered_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ month)
+ _stats_render_month_section "$heading" "$array_name" "$prefix" \
+ "$total"
+ ;;
+ *)
+ printf 'ERROR: unknown stats render kind %q for %s\n' \
+ "$render_kind" "$array_name" >&2
+ return 1
+ ;;
+ esac
}
-# Assemble the full stats body from every section in display order. Returns the
-# HTML on stdout; render_stats_page captures it into the stats_body context var.
+# Assemble the full stats body by iterating STATS_CATEGORIES in registry order
+# (which IS the display order). Returns the HTML on stdout; render_stats_page
+# captures it into the stats_body context var. Adding a category appends one
+# registry entry -- no edit here.
_stats_build_body() {
local -ri total="${STATS_TOTALS[photos]:-0}"
+ local spec
printf '<p class="stats-total">%d photos analysed.</p>\n' "$total"
- _stats_render_camera_section "$total"
- _stats_render_temporal_sections "$total"
- _stats_render_exposure_sections "$total"
- _stats_render_dimension_sections "$total"
- _stats_render_enum_sections "$total"
+ for spec in "${STATS_CATEGORIES[@]}"; do
+ _stats_render_category "$spec" "$total"
+ done
}
# Public render entry point (handoff for task rm0). Builds the body from the
diff --git a/tests/cli.sh b/tests/cli.sh
index ad2a1d7..479a998 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -5877,6 +5877,84 @@ test_stats_collect_reads_cached_identify_output() {
test::teardown
}
+# STATS_CATEGORIES is the single source of truth (task en0): proves the reset,
+# the overview body builder, and the bucket ladders all derive from the registry,
+# so a category can no longer be defined in only one place. A regression that
+# added a category to (say) the body builder without registering it -- or
+# registered one without resetting its array, or an 'ordered' kind without its
+# bucket ladder -- would fail one of the