diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-17 22:04:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-17 22:04:41 +0300 |
| commit | 5fb1f17e3b16c8ef4b71648419251d76ae1227b2 (patch) | |
| tree | 994bdb3e5fe6f291226a48c7734106459e19b291 /src/lib/stats-aggregate.source.sh | |
| parent | eb8cbbbcb9a613757ac4440246c43ebba6758afe (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>
Diffstat (limited to 'src/lib/stats-aggregate.source.sh')
| -rw-r--r-- | src/lib/stats-aggregate.source.sh | 132 |
1 files changed, 110 insertions, 22 deletions
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" } |
