summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-28 08:57:44 +0300
committerPaul Buetow <paul@buetow.org>2026-06-28 08:57:44 +0300
commitfef68fe90d7f00d7d831c335b03119f11c2c927b (patch)
treec2f04c632e76a41732940bc947d896744dac6ba6 /src
parent5c7b3a78f2195c217453eda86818d340332e76c5 (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.sh136
-rw-r--r--src/lib/stats-filter-album.source.sh29
-rw-r--r--src/lib/stats-render.source.sh94
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"