diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-28 08:57:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-28 08:57:44 +0300 |
| commit | fef68fe90d7f00d7d831c335b03119f11c2c927b (patch) | |
| tree | c2f04c632e76a41732940bc947d896744dac6ba6 /src | |
| parent | 5c7b3a78f2195c217453eda86818d340332e76c5 (diff) | |
or0: encapsulate STATS_* maps behind aggregator accessors
The stats reader modules (stats-render.source.sh, stats-filter-album.source.sh)
indexed stats-aggregate.source.sh's private STATS_* associative arrays directly,
so a key-convention change in the aggregator would silently break both readers.
Add a read API owned by stats-aggregate.source.sh (the data owner), mirroring
album-render's ALBUM_VIEW_PAGE_BY_PHOTO / album_view_page_for_photo split:
stats_total_photos - STATS_TOTALS[photos] denominator
stats_filter_pagebase - (prefix,label) -> pagebase, hiding the
STATS_FILTER_KEYSEP catkey encoding
stats_filter_title - STATS_FILTER_TITLE[pagebase]
stats_filter_photos - STATS_FILTER_PHOTOS[pagebase] list
stats_filter_count - number of filter mini-albums
stats_filter_pagebases - pagebases, LC_ALL=C-sorted (order owned here)
stats_category_count/size/max/keys_by_count_desc
- per-category count-array reads
Route every cross-module STATS_* read through these accessors. The render
module's _stats_max_count / _stats_keys_by_count_desc were duplicates of the new
stats_category_max / stats_category_keys_by_count_desc, so they are removed.
Behavior preserved exactly: missing-key semantics, iteration order (ordered
ladders, calendar months, count-desc with LC_ALL=C tie-break, sorted pagebase
enqueue) and generated HTML are byte-identical. Header docs updated to describe
the accessor boundary. The arrays stay the backing store; only the cross-module
READ path is encapsulated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/stats-aggregate.source.sh | 136 | ||||
| -rw-r--r-- | src/lib/stats-filter-album.source.sh | 29 | ||||
| -rw-r--r-- | src/lib/stats-render.source.sh | 94 |
3 files changed, 188 insertions, 71 deletions
diff --git a/src/lib/stats-aggregate.source.sh b/src/lib/stats-aggregate.source.sh index 24a0687..3c013b3 100644 --- a/src/lib/stats-aggregate.source.sh +++ b/src/lib/stats-aggregate.source.sh @@ -12,8 +12,13 @@ # Public API / handoff contract # ---------------------------------------------------------------------------- # collect_photo_exif_stats iterates the album's photos and fills these globals -# (all declared with `declare -gA` so the render tasks can read them after the -# call without namerefs): +# (all declared with `declare -gA`). They are this module's PRIVATE backing +# store: the reader modules (stats-render.source.sh, stats-filter-album.source.sh) +# do NOT index them directly -- they go through the accessor functions at the end +# of this file (stats_total_photos, stats_filter_pagebase, stats_filter_title, +# stats_filter_photos, stats_filter_count, stats_filter_pagebases, and the +# stats_category_* count accessors). That keeps the readers decoupled from the +# key conventions used below. The arrays filled here are: # # STATS_CAMERAS[<camera label>] = count (camera leaderboard) # STATS_LENSES[<lens model>] = count (sparse; may be empty) @@ -662,3 +667,130 @@ collect_photo_exif_stats() { < <(cached_photo_identify_output "$photo" "$INCOMING_DIR/$photo") done < <(incoming_image_files) } + +# ---------------------------------------------------------------------------- +# Public read API (task or0) +# ---------------------------------------------------------------------------- +# The STATS_* maps above are this module's PRIVATE backing store. The reader +# modules (stats-render.source.sh, stats-filter-album.source.sh) must NOT index +# them directly: they call the accessors below instead. Keeping the maps private +# behind documented functions decouples the readers from the aggregator's key +# conventions (the "<prefix>\x1f<label>" pagebase keys, the per-category count +# arrays, the photos-total counter), so a change to how a key is built or a map +# is named stays contained in this module -- mirroring album-render's +# ALBUM_VIEW_PAGE_BY_PHOTO / album_view_page_for_photo split. + +# Print the number of photos analysed (the percentage/scale denominator), or 0 +# before any aggregation ran. Encapsulates STATS_TOTALS[photos]; matches the +# render side's former "${STATS_TOTALS[photos]:-0}" missing-key default. +stats_total_photos() { + printf '%d' "${STATS_TOTALS[photos]:-0}" +} + +# Print the filename-safe pagebase for a (prefix, label) filter bucket, or the +# empty string when that bucket was never tallied. Encapsulates both the +# STATS_FILTER_KEYSEP catkey encoding and the STATS_FILTER_PAGEBASE map, so the +# stats overview can link each bar to its mini-album without knowing how keys are +# built. Matches the former "${STATS_FILTER_PAGEBASE[$catkey]:-}" lookup. +stats_filter_pagebase() { + local -r prefix="$1"; shift + local -r label="$1"; shift + local -r catkey="$prefix$STATS_FILTER_KEYSEP$label" + + printf '%s' "${STATS_FILTER_PAGEBASE[$catkey]:-}" +} + +# Print the human gallery heading recorded for a filter pagebase, or the empty +# string when unknown. Encapsulates STATS_FILTER_TITLE; matches the filter +# module's former "${STATS_FILTER_TITLE[$pagebase]:-}" lookup. +stats_filter_title() { + local -r pagebase="$1"; shift + + printf '%s' "${STATS_FILTER_TITLE[$pagebase]:-}" +} + +# Print the newline-separated photo list recorded for a filter pagebase. +# Encapsulates STATS_FILTER_PHOTOS for the per-pagebase read sites. Callers that +# always pass a known pagebase relied on the bare "${STATS_FILTER_PHOTOS[...]}" +# (no :- default); under set -u an unknown pagebase would have errored, so this +# preserves that by also using the bare lookup. +stats_filter_photos() { + local -r pagebase="$1"; shift + + printf '%s' "${STATS_FILTER_PHOTOS[$pagebase]}" +} + +# Print the number of filter mini-albums tallied (0 when none). Encapsulates the +# "${#STATS_FILTER_PHOTOS[@]}" size read render_filter_pages uses to skip work +# when there is nothing to render. +stats_filter_count() { + printf '%d' "${#STATS_FILTER_PHOTOS[@]}" +} + +# Print every filter pagebase, one per line, in LC_ALL=C-sorted order so the +# enqueue order is reproducible. Encapsulates the "${!STATS_FILTER_PHOTOS[@]}" +# key enumeration; the sort is pinned here (not in the caller) so the order is +# owned alongside the data, and reproduces render_filter_pages's former +# "printf ... "${!STATS_FILTER_PHOTOS[@]}" | LC_ALL=C sort" exactly. +stats_filter_pagebases() { + printf '%s\n' "${!STATS_FILTER_PHOTOS[@]}" | LC_ALL=C sort +} + +# ---------------------------------------------------------------------------- +# Per-category count accessors (task or0) +# ---------------------------------------------------------------------------- +# The stats overview renders each category from its STATS_CATEGORIES spec, whose +# first field names that category's count array (STATS_CAMERAS, STATS_ISO, ...). +# The render handlers used to nameref that array directly; they now ask the +# aggregator through these accessors, so the count arrays stay private here. +# array_name is the registry's count_array field (a trusted internal name); the +# accessors index it via a local nameref. + +# Print the count stored for one bucket key, or the empty string when the bucket +# never occurred. Mirrors the render side's former "${counts_ref[$key]:-}" lookup +# so an absent bucket reads as empty (skipped) and a present one reads as its +# positive count. +stats_category_count() { + local -n _stats_counts_ref="$1"; shift + local -r key="$1"; shift + + printf '%s' "${_stats_counts_ref[$key]:-}" +} + +# Print the number of buckets that occurred in a category's count array (0 when +# empty). Encapsulates the "${#counts_ref[@]}" size read the render handlers use +# to skip an empty category's whole section. +stats_category_size() { + local -n _stats_counts_ref="$1"; shift + + printf '%d' "${#_stats_counts_ref[@]}" +} + +# Print the largest count in a category's array, or 0 when it is empty. Used to +# scale each section's bars relative to its own busiest bucket. Encapsulates the +# render side's former _stats_max_count loop over the array's values. +stats_category_max() { + local -n _stats_counts_ref="$1"; shift + local key + local -i max=0 + + for key in "${!_stats_counts_ref[@]}"; do + if (( _stats_counts_ref[key] > max )); then + max=${_stats_counts_ref[$key]} + fi + done + printf '%d' "$max" +} + +# Print a category's bucket keys ordered by descending count (ties broken by key) +# so the busiest bucket leads. Encapsulates the render side's former +# _stats_keys_by_count_desc. LC_ALL=C pins the tie-break collation so the +# generated page is byte-identical across locales/machines. +stats_category_keys_by_count_desc() { + local -n _stats_counts_ref="$1"; shift + local key + + for key in "${!_stats_counts_ref[@]}"; do + printf '%d\t%s\n' "${_stats_counts_ref[$key]}" "$key" + done | LC_ALL=C sort -t $'\t' -k1,1nr -k2,2 | cut -f2- +} diff --git a/src/lib/stats-filter-album.source.sh b/src/lib/stats-filter-album.source.sh index 562a72e..da37886 100644 --- a/src/lib/stats-filter-album.source.sh +++ b/src/lib/stats-filter-album.source.sh @@ -2,11 +2,15 @@ # so this concern is separate from the EXIF aggregation (stats-aggregate.source.sh) # and the stats overview page (stats-render.source.sh). Every tallied bucket # becomes a clickable mini album under dist/stats/<pagebase>/. This module reads -# the STATS_FILTER_* globals filled by collect_photo_exif_stats and resolves each -# photo's album view page through the album_view_page_for_photo accessor (task -# pn0) instead of indexing the album's private ALBUM_VIEW_PAGE_BY_PHOTO global, -# so stats stays decoupled from album-internal page naming/caching. All libs are -# sourced before run so cross-module references resolve. +# the aggregated filter data filled by collect_photo_exif_stats -- through the +# stats-aggregate.source.sh accessors (stats_filter_count, stats_filter_pagebases, +# stats_filter_photos, stats_filter_title), never by indexing the aggregator's +# private STATS_FILTER_* maps directly -- and resolves each photo's album view +# page through the album_view_page_for_photo accessor (task pn0) instead of +# indexing the album's private ALBUM_VIEW_PAGE_BY_PHOTO global, so stats stays +# decoupled from both the aggregator's key conventions and album-internal page +# naming/caching. All libs are sourced before run so cross-module references +# resolve. # ---------------------------------------------------------------------------- # Filter mini-album pages @@ -89,14 +93,15 @@ _stats_render_filter_gallery() { local -r html_dir="$STATS_DIR/$pagebase" local -r backhref="$STATS_FILTER_BACKHREF" local -r backhref_html="$STATS_FILTER_BACKHREF" - local -r title="${STATS_FILTER_TITLE[$pagebase]:-}" + local title + title=$(stats_filter_title "$pagebase") thumbs=$(_stats_build_filter_thumbs \ - "$backhref_html" "${STATS_FILTER_PHOTOS[$pagebase]}") + "$backhref_html" "$(stats_filter_photos "$pagebase")") # Background fits the category: a random photo from this filter's own set, # mirroring how the album preview pages pick a random album photo. background_image=$(_stats_pick_background \ - "$pagebase" "${STATS_FILTER_PHOTOS[$pagebase]}") + "$pagebase" "$(stats_filter_photos "$pagebase")") template header index.html \ html_dir "$html_dir" backhref "$backhref" \ blurs_dir "$STATS_BLURS_DIR" background_image "$background_image" \ @@ -210,7 +215,7 @@ _stats_enqueue_filter_album() { while IFS= read -r photo; do [ -n "$photo" ] && photo_list+=("$photo") - done <<< "${STATS_FILTER_PHOTOS[$pagebase]}" + done <<< "$(stats_filter_photos "$pagebase")" n=${#photo_list[@]} job_pool_submit "$pool" "filter gallery $pagebase" \ @@ -236,7 +241,7 @@ _stats_enqueue_filter_album() { render_filter_pages() { local pagebase - if (( ${#STATS_FILTER_PHOTOS[@]} == 0 )); then + if (( $(stats_filter_count) == 0 )); then return fi @@ -244,9 +249,11 @@ render_filter_pages() { # "render_jobs". job_pool_wait returns 1 if any render job failed. job_pool_init render_jobs + # stats_filter_pagebases yields the pagebases in LC_ALL=C-sorted order, so the + # enqueue order is reproducible. while IFS= read -r pagebase; do _stats_enqueue_filter_album "$pagebase" render_jobs - done < <(printf '%s\n' "${!STATS_FILTER_PHOTOS[@]}" | LC_ALL=C sort) + done < <(stats_filter_pagebases) job_pool_wait render_jobs } diff --git a/src/lib/stats-render.source.sh b/src/lib/stats-render.source.sh index e9b99c2..583c511 100644 --- a/src/lib/stats-render.source.sh +++ b/src/lib/stats-render.source.sh @@ -1,11 +1,15 @@ # Stats overview page rendering. Split out of stats.source.sh (task cn0) so the # HTML/layout concern lives apart from the EXIF aggregation/bucketing (now in # stats-aggregate.source.sh) and the per-filter mini-albums (now in -# stats-filter-album.source.sh). This module reads the STATS_* globals filled by -# collect_photo_exif_stats and turns them into the static stats overview page -# (bar charts, sections, camera leaderboard). All libs are sourced before run, -# so the STATS_* maps and the STATS_*_DIR constants defined in the sibling -# modules are available here at runtime. +# stats-filter-album.source.sh). This module reads the aggregated stats filled by +# collect_photo_exif_stats -- through the stats-aggregate.source.sh accessor +# functions (stats_total_photos, stats_filter_pagebase, stats_category_*), never +# by indexing the aggregator's private STATS_* maps directly -- and turns them +# into the static stats overview page (bar charts, sections, camera leaderboard). +# It still reads the shared registry constants it owns a stake in (STATS_CATEGORIES, +# STATS_CATEGORY_BUCKETS) and the STATS_*_DIR layout constants from the sibling +# modules. All libs are sourced before run, so the accessors and constants are +# available here at runtime. # ---------------------------------------------------------------------------- # Rendering (task pm0) @@ -19,23 +23,9 @@ # it with the shared header/footer chrome. Bars are plain CSS (width as a percent # of the section's top bucket) so the output stays JavaScript-free. -# Print the largest counter in the named stats array, or 0 when it is empty. -# Used to scale each section's bars relative to its own busiest bucket. -_stats_max_count() { - local -n counts_ref="$1"; shift - local key - local -i max=0 - - for key in "${!counts_ref[@]}"; do - if (( counts_ref[key] > max )); then - max=${counts_ref[$key]} - fi - done - printf '%d' "$max" -} - # Print the integer percentage count/total (0 when total is 0). awk keeps the -# rounding off bash integer math; STATS_TOTALS[photos] is the denominator. +# rounding off bash integer math; the denominator is the photo total the caller +# obtained from the stats_total_photos accessor. _stats_percent() { local -ri count="$1"; shift local -ri total="$1"; shift @@ -95,18 +85,18 @@ _stats_section_close() { } # Wrap an escaped label in a link to its filter mini-album. Every tallied bucket -# has a pagebase recorded in STATS_FILTER_PAGEBASE during aggregation, keyed by -# "<prefix>\x1f<label>"; if one is (unexpectedly) absent, the plain label is -# returned so the row still renders. The stats page and the filter pages share -# the dist root, so the href is just "<pagebase>.html". +# has a pagebase recorded during aggregation; the stats_filter_pagebase accessor +# resolves the (prefix, label) bucket to it (encapsulating the aggregator's catkey +# encoding). If one is (unexpectedly) absent, the accessor returns the empty +# string and the plain label is returned so the row still renders. The stats page +# and the filter pages share the dist root, so the href is just "<pagebase>.html". _stats_filter_link() { local -r prefix="$1"; shift local -r label="$1"; shift local -r label_html="$1"; shift - local -r catkey="$prefix$STATS_FILTER_KEYSEP$label" local pagebase - pagebase="${STATS_FILTER_PAGEBASE[$catkey]:-}" + pagebase=$(stats_filter_pagebase "$prefix" "$label") if [ -n "$pagebase" ]; then # The stats overview lives at stats/index.html and each mini-album at # stats/<pagebase>/index.html, so link relative to the overview. @@ -116,19 +106,6 @@ _stats_filter_link() { fi } -# 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 -# across locales/machines (reproducible static output). -_stats_keys_by_count_desc() { - local -n counts_ref="$1"; shift - local key - - for key in "${!counts_ref[@]}"; do - printf '%d\t%s\n' "${counts_ref[$key]}" "$key" - done | LC_ALL=C sort -t $'\t' -k1,1nr -k2,2 | cut -f2- -} - # 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. The # bucket ladder is read from STATS_CATEGORY_BUCKETS[array_name] (tab-delimited). @@ -142,24 +119,24 @@ _stats_render_section__ordered() { local -r array_name="$1"; shift local -r prefix="$1"; shift local -ri total="$1"; shift - local -n counts_ref="$array_name" - local bucket + local bucket count local row_html local -a buckets=() local -i max - if (( ${#counts_ref[@]} == 0 )); then + if (( $(stats_category_size "$array_name") == 0 )); then return fi IFS=$'\t' read -r -a buckets <<< "${STATS_CATEGORY_BUCKETS[$array_name]}" - max=$(_stats_max_count "$array_name") + max=$(stats_category_max "$array_name") _stats_section_open "$heading" for bucket in "${buckets[@]}"; do - if [ -z "${counts_ref[$bucket]:-}" ]; then + count=$(stats_category_count "$array_name" "$bucket") + if [ -z "$count" ]; then continue fi row_html=$(_stats_filter_link "$prefix" "$bucket" "$(html_escape "$bucket")") - _stats_bar_row "$row_html" "${counts_ref[$bucket]}" "$total" "$max" + _stats_bar_row "$row_html" "$count" "$total" "$max" done _stats_section_close } @@ -178,20 +155,20 @@ _stats_render_section__ranked() { 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 local -i max - if (( ${#counts_ref[@]} == 0 )); then + if (( $(stats_category_size "$array_name") == 0 )); then return fi - max=$(_stats_max_count "$array_name") + max=$(stats_category_max "$array_name") _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" - done < <(_stats_keys_by_count_desc "$array_name") + _stats_bar_row "$row_html" \ + "$(stats_category_count "$array_name" "$key")" "$total" "$max" + done < <(stats_category_keys_by_count_desc "$array_name") _stats_section_close } @@ -206,27 +183,27 @@ _stats_render_section__month() { 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 ) local -i month - local key + local key count local -i max - if (( ${#counts_ref[@]} == 0 )); then + if (( $(stats_category_size "$array_name") == 0 )); then return fi - max=$(_stats_max_count "$array_name") + max=$(stats_category_max "$array_name") _stats_section_open "$heading" for (( month = 1; month <= 12; month++ )); do key=$(printf '%02d' "$month") - if [ -z "${counts_ref[$key]:-}" ]; then + count=$(stats_category_count "$array_name" "$key") + if [ -z "$count" ]; then continue fi _stats_bar_row \ "$(_stats_filter_link "$prefix" "$key" "${month_names[$month]}")" \ - "${counts_ref[$key]}" "$total" "$max" + "$count" "$total" "$max" done _stats_section_close } @@ -269,9 +246,10 @@ _stats_render_category() { # 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 -i total local spec + total=$(stats_total_photos) printf '<p class="stats-total">%d photos analysed.</p>\n' "$total" for spec in "${STATS_CATEGORIES[@]}"; do _stats_render_category "$spec" "$total" |
