diff options
| -rwxr-xr-x | bin/shuriken | 295 | ||||
| -rw-r--r-- | src/lib/stats-aggregate.source.sh | 132 | ||||
| -rw-r--r-- | src/lib/stats-render.source.sh | 167 | ||||
| -rwxr-xr-x | tests/cli.sh | 81 |
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 these assertions. +test_stats_categories_registry_is_single_source_of_truth() { + local spec + local array_name + local render_kind + local heading + local -A registry_arrays=() + local -A rendered_headings=() + local -a fields=() + local body + + test::setup + test::source_shuriken_lib + + # 1) reset_photo_exif_stats must clear exactly the registry's count arrays + # (no more, no less): a registered category gets a fresh empty array. + reset_photo_exif_stats + for spec in "${STATS_CATEGORIES[@]}"; do + IFS='|' read -r -a fields <<< "$spec" + array_name="${fields[0]}" + render_kind="${fields[3]}" + registry_arrays["$array_name"]=1 + # The array exists and is an (empty) associative array after reset. + if ! declare -p "$array_name" >/dev/null 2>&1; then + printf 'FAIL: reset did not declare registry array %s\n' \ + "$array_name" >&2 + exit 1 + fi + # Every 'ordered' category must supply a bucket ladder; nothing else may. + if [ "$render_kind" = ordered ]; then + if [ -z "${STATS_CATEGORY_BUCKETS[$array_name]:-}" ]; then + printf 'FAIL: ordered category %s has no bucket ladder\n' \ + "$array_name" >&2 + exit 1 + fi + fi + done + for array_name in "${!STATS_CATEGORY_BUCKETS[@]}"; do + if [ -z "${registry_arrays[$array_name]:-}" ]; then + printf 'FAIL: bucket ladder %s is not a registered category\n' \ + "$array_name" >&2 + exit 1 + fi + done + + # 2) The overview body builder renders only registry headings: feed one photo + # that lights up several categories, then assert every <h2> heading in the + # body comes from a STATS_CATEGORIES entry (so no out-of-band section). + reset_photo_exif_stats + accumulate_photo_stats 'a |
