diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-14 22:16:43 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-14 22:16:43 +0300 |
| commit | 5555b8e996dc42d319c4ef7115307d10a25de7f3 (patch) | |
| tree | 784565a831c8fcf397bbe165472757d7873f221e | |
| parent | c08428d381b2db644a48094e105c2289ba090fb9 (diff) | |
pm0 add stats page template and render function
Render the aggregated STATS_* counters from the om0 module into a static
stats.html. render_stats_page builds the variable-length body (camera
leaderboard, temporal/exposure/dimension histograms, format and enum
breakdowns) as HTML and hands it to the new stats.tmpl through a raw
context field, wrapping it with the shared header/footer the way
view/details pages do. Bars are pure CSS so the page stays JavaScript-free.
- new template share/templates/default/stats.tmpl (page chrome + body)
- render_stats_page <html_dir> <backhref> [page_name] plus small section
builders in src/lib/stats.source.sh; EXIF-derived labels are HTML-escaped
- new TEMPLATE_RENDER_FIELD_SPECS field render_stats_body_html (context_raw)
and 'stats' added to render_backhref_html's required list
- camera leaderboard entries link to camera-<slug>.html (um0 owns those)
- Stats nav link wired into the shared header bar
- tests cover leaderboard links, counts/percentages, a histogram section,
&/< escaping, and omission of empty categories
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rwxr-xr-x | bin/shuriken | 299 | ||||
| -rw-r--r-- | share/templates/default/header.tmpl | 63 | ||||
| -rw-r--r-- | share/templates/default/stats.tmpl | 16 | ||||
| -rw-r--r-- | src/lib/stats.source.sh | 296 | ||||
| -rw-r--r-- | src/lib/template.source.sh | 3 | ||||
| -rwxr-xr-x | tests/cli.sh | 87 |
6 files changed, 762 insertions, 2 deletions
diff --git a/bin/shuriken b/bin/shuriken index 7ef01fc..f3a2044 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -606,7 +606,7 @@ current_date_text_to() { declare -ra TEMPLATE_RENDER_FIELD_SPECS=( 'render_animation_class_html|context_html|animation_class|animation_class|preview details view' 'render_backhref_css|context_css|backhref|backhref|header splash' - 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view' + 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view stats' 'render_background_image_css|context_css|background_image|background_image|header splash' 'render_blurs_dir_css|context_css|blurs_dir|blurs_dir|header splash' 'render_current_date_text|current_date_html|||' @@ -626,6 +626,7 @@ declare -ra TEMPLATE_RENDER_FIELD_SPECS=( 'render_preview_num_html|context_html|preview_num|preview_num|preview details view' 'render_redirect_page_html|context_html|redirect_page|redirect_page|redirect' 'render_show_header_bar|context_raw|show_header_bar|show_header_bar|header' + 'render_stats_body_html|context_raw|stats_body|stats_body|stats' 'render_tarball_include|tarball_include|||' 'render_tarball_name_html|context_html|tarball_name|tarball_name|footer' 'render_thumbheight_html|config_html|THUMBHEIGHT||' @@ -3278,6 +3279,302 @@ collect_photo_exif_stats() { done < <(incoming_image_files) } +# ---------------------------------------------------------------------------- +# Rendering (task pm0) +# ---------------------------------------------------------------------------- +# render_stats_page turns the STATS_* globals filled by the aggregation above +# into a static stats.html. The page is variable-length (each category may be +# empty, sparse, or large) which does not fit the fixed field-spec template +# engine cleanly, so we follow the same approach view/details pages use for +# their dynamic EXIF table: build the whole body as an HTML string here, hand it +# to stats.tmpl through the raw context field stats_body, and let the engine wrap +# 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. +_stats_percent() { + local -ri count="$1"; shift + local -ri total="$1"; shift + + if (( total <= 0 )); then + printf '0' + return + fi + awk -v c="$count" -v t="$total" 'BEGIN { printf "%.0f", 100 * c / t }' +} + +# Emit one bar-chart <li>: an already-escaped label, a CSS-width bar scaled to +# the section maximum, and the count plus its share of all photos. Callers escape +# labels themselves because some come from EXIF (camera/lens) and some are +# trusted bucket names we build internally. +_stats_bar_row() { + local -r label_html="$1"; shift + local -ri count="$1"; shift + local -ri total="$1"; shift + local -ri max="$1"; shift + local -i width=0 + + if (( max > 0 )); then + width=$(( 100 * count / max )) + fi + printf ' <li>' + printf '<span class="stats-label">%s</span>' "$label_html" + printf '<span class="stats-bar-track">' + printf '<span class="stats-bar-fill" style="width:%d%%"></span></span>' \ + "$width" + printf '<span class="stats-count">%d (%s%%)</span>' \ + "$count" "$(_stats_percent "$count" "$total")" + printf '</li>\n' +} + +# Open a <section> with an escaped heading and the <ul> bar container. Split from +# the row emitters so every section shares identical chrome. +_stats_section_open() { + local -r heading="$1"; shift + local heading_html + + heading_html=$(_html_escape "$heading") + printf '<section class="stats-section">\n' + printf '<h2>%s</h2>\n' "$heading_html" + printf '<ul class="stats-bars">\n' +} + +_stats_section_close() { + printf '</ul>\n</section>\n' +} + +# Render the camera leaderboard: one bar per camera, sorted by count descending, +# each label linking to camera-<slug>.html (built by sibling task um0). Camera +# labels come from EXIF, so the label text is HTML-escaped; the slug is filename +# -safe by construction. Skipped entirely when no camera data was collected. +_stats_render_camera_section() { + local -ri total="$1"; shift + local label + local label_html + local link_html + local slug + local -i max + + if (( ${#STATS_CAMERAS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_CAMERAS) + _stats_section_open 'Camera leaderboard' + while IFS= read -r label; do + label_html=$(_html_escape "$label") + slug="${STATS_CAMERA_SLUGS[$label]}" + link_html=$(printf '<a href="camera-%s.html">%s</a>' \ + "$slug" "$label_html") + _stats_bar_row "$link_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. +_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 | 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. 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 + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local bucket + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + for bucket in "$@"; do + if [ -z "${counts_ref[$bucket]:-}" ]; then + continue + fi + label_html=$(_html_escape "$bucket") + _stats_bar_row "$label_html" "${counts_ref[$bucket]}" "$total" "$max" + done + _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. +_stats_render_ranked_section() { + local -r heading="$1"; shift + local -r array_name="$1"; shift + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local key + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + while IFS= read -r key; do + label_html=$(_html_escape "$key") + _stats_bar_row "$label_html" "${counts_ref[$key]}" "$total" "$max" + done < <(_stats_keys_by_count_desc "$array_name") + _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 "$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. +_stats_render_month_section() { + local -ri total="$1"; shift + local -ra month_names=( + '' January February March April May June July August + September October November December ) + local -i month + local key + local -i max + + if (( ${#STATS_MONTHS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_MONTHS) + _stats_section_open 'Photos per month' + for (( month = 1; month <= 12; month++ )); do + key=$(printf '%02d' "$month") + if [ -z "${STATS_MONTHS[$key]:-}" ]; then + continue + fi + _stats_bar_row "${month_names[$month]}" \ + "${STATS_MONTHS[$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 "$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 "$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 "$total" \ + '50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \ + 'over 25600' + _stats_render_ordered_section 'Focal length' STATS_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 "$total" \ + 'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP' + _stats_render_ordered_section 'Aspect ratio' STATS_ASPECT "$total" \ + '3:2' '4:3' '16:9' '1:1' '5:4' 'other' + _stats_render_ordered_section 'Orientation' STATS_ORIENTATION "$total" \ + 'Landscape' 'Portrait' 'Square' + _stats_render_ordered_section 'File format' STATS_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 + + _stats_render_ranked_section 'Lenses' STATS_LENSES "$total" + _stats_render_ranked_section 'Exposure program' \ + STATS_EXPOSURE_PROGRAM "$total" + _stats_render_ranked_section 'Metering mode' STATS_METERING "$total" + _stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE "$total" + _stats_render_ranked_section 'Flash' STATS_FLASH "$total" +} + +# 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. +_stats_build_body() { + local -ri total="${STATS_TOTALS[photos]:-0}" + + 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" +} + +# Public render entry point (handoff for task rm0). Builds the body from the +# already-populated STATS_* globals and renders stats.html via the template +# engine, wrapping the body with the shared header/footer the way view/details +# pages do. Call collect_photo_exif_stats first to fill the globals. +# render_stats_page <html_dir> <backhref> [page_name] +# html_dir is the dist-relative output directory (top-level album: "."), +# backhref is the relative path back to the album root ("." for a top-level +# stats.html), and page_name defaults to "stats" -> stats.html. +render_stats_page() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_name="${1:-stats}" + local stats_body + + stats_body=$(_stats_build_body) + template header "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir '' \ + background_image '' \ + show_header_bar 'yes' + template stats "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + stats_body "$stats_body" + template footer "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name '' +} + # Inlined from src/lib/config.source.sh existing_parent_dir() { local -r path="$1"; shift diff --git a/share/templates/default/header.tmpl b/share/templates/default/header.tmpl index 953109a..e0acc6f 100644 --- a/share/templates/default/header.tmpl +++ b/share/templates/default/header.tmpl @@ -95,6 +95,67 @@ cat <<END white-space: nowrap; } + div.stats { + margin: 0 auto; + max-width: 900px; + text-align: left; + } + + h1.stats-title { + text-align: center; + } + + section.stats-section { + background-color: #000000; + border: 3px solid #ffffff; + margin: 8px auto; + padding: 10px; + } + + section.stats-section h2 { + font-size: 1.1em; + margin-top: 0; + } + + /* Each bar row is a label, a proportional bar, and the count/percent. + Bars are pure CSS (width as a percentage of the top bucket) so the + page stays static HTML with no JavaScript or SVG. */ + ul.stats-bars { + list-style: none; + margin: 0; + padding: 0; + } + + ul.stats-bars li { + align-items: center; + display: flex; + gap: 8px; + margin: 2px 0; + } + + span.stats-label { + flex: 0 0 9em; + word-break: break-word; + } + + span.stats-bar-track { + background-color: #222222; + flex: 1 1 auto; + height: 1.1em; + } + + span.stats-bar-fill { + background-color: #ffffff; + display: block; + height: 100%; + } + + span.stats-count { + flex: 0 0 7em; + text-align: right; + white-space: nowrap; + } + img { padding: 5px; } @@ -215,6 +276,8 @@ cat <<END Site generated at ${render_current_date_text} with <a href="https://codeberg.org/snonux/shuriken.sh">codeberg.org/snonux/shuriken.sh</a> - © by Paul Buetow + - + <a href="${render_backhref_html}/stats.html">Stats</a> </div> END fi diff --git a/share/templates/default/stats.tmpl b/share/templates/default/stats.tmpl new file mode 100644 index 0000000..7a94b60 --- /dev/null +++ b/share/templates/default/stats.tmpl @@ -0,0 +1,16 @@ +# Stats page body. All of the variable-length, data-driven markup (camera +# leaderboard, histograms, format breakdown, enum tables) is pre-rendered by +# render_stats_page in src/lib/stats.source.sh and handed in through the raw +# context field render_stats_body_html, so this template only supplies the page +# chrome: the heading, the pre-built body, and a link back to the album. The +# surrounding <html>/<head>/<body> and footer come from the header/footer +# templates, which render_stats_page emits around this one (same as view/details). +cat <<END +<div class="view stats"> + <h1 class="stats-title">${render_title_html} — Stats</h1> + <div class="stats-back navigator"> + <a href="${render_backhref_html}/index.html">Back to album</a> + </div> + ${render_stats_body_html} +</div> +END diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh index cdb8668..f4f2e1f 100644 --- a/src/lib/stats.source.sh +++ b/src/lib/stats.source.sh @@ -548,3 +548,299 @@ collect_photo_exif_stats() { < <(cached_photo_identify_output "$photo" "$INCOMING_DIR/$photo") done < <(incoming_image_files) } + +# ---------------------------------------------------------------------------- +# Rendering (task pm0) +# ---------------------------------------------------------------------------- +# render_stats_page turns the STATS_* globals filled by the aggregation above +# into a static stats.html. The page is variable-length (each category may be +# empty, sparse, or large) which does not fit the fixed field-spec template +# engine cleanly, so we follow the same approach view/details pages use for +# their dynamic EXIF table: build the whole body as an HTML string here, hand it +# to stats.tmpl through the raw context field stats_body, and let the engine wrap +# 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. +_stats_percent() { + local -ri count="$1"; shift + local -ri total="$1"; shift + + if (( total <= 0 )); then + printf '0' + return + fi + awk -v c="$count" -v t="$total" 'BEGIN { printf "%.0f", 100 * c / t }' +} + +# Emit one bar-chart <li>: an already-escaped label, a CSS-width bar scaled to +# the section maximum, and the count plus its share of all photos. Callers escape +# labels themselves because some come from EXIF (camera/lens) and some are +# trusted bucket names we build internally. +_stats_bar_row() { + local -r label_html="$1"; shift + local -ri count="$1"; shift + local -ri total="$1"; shift + local -ri max="$1"; shift + local -i width=0 + + if (( max > 0 )); then + width=$(( 100 * count / max )) + fi + printf ' <li>' + printf '<span class="stats-label">%s</span>' "$label_html" + printf '<span class="stats-bar-track">' + printf '<span class="stats-bar-fill" style="width:%d%%"></span></span>' \ + "$width" + printf '<span class="stats-count">%d (%s%%)</span>' \ + "$count" "$(_stats_percent "$count" "$total")" + printf '</li>\n' +} + +# Open a <section> with an escaped heading and the <ul> bar container. Split from +# the row emitters so every section shares identical chrome. +_stats_section_open() { + local -r heading="$1"; shift + local heading_html + + heading_html=$(_html_escape "$heading") + printf '<section class="stats-section">\n' + printf '<h2>%s</h2>\n' "$heading_html" + printf '<ul class="stats-bars">\n' +} + +_stats_section_close() { + printf '</ul>\n</section>\n' +} + +# Render the camera leaderboard: one bar per camera, sorted by count descending, +# each label linking to camera-<slug>.html (built by sibling task um0). Camera +# labels come from EXIF, so the label text is HTML-escaped; the slug is filename +# -safe by construction. Skipped entirely when no camera data was collected. +_stats_render_camera_section() { + local -ri total="$1"; shift + local label + local label_html + local link_html + local slug + local -i max + + if (( ${#STATS_CAMERAS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_CAMERAS) + _stats_section_open 'Camera leaderboard' + while IFS= read -r label; do + label_html=$(_html_escape "$label") + slug="${STATS_CAMERA_SLUGS[$label]}" + link_html=$(printf '<a href="camera-%s.html">%s</a>' \ + "$slug" "$label_html") + _stats_bar_row "$link_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. +_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 | 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. 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 + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local bucket + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + for bucket in "$@"; do + if [ -z "${counts_ref[$bucket]:-}" ]; then + continue + fi + label_html=$(_html_escape "$bucket") + _stats_bar_row "$label_html" "${counts_ref[$bucket]}" "$total" "$max" + done + _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. +_stats_render_ranked_section() { + local -r heading="$1"; shift + local -r array_name="$1"; shift + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local key + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + while IFS= read -r key; do + label_html=$(_html_escape "$key") + _stats_bar_row "$label_html" "${counts_ref[$key]}" "$total" "$max" + done < <(_stats_keys_by_count_desc "$array_name") + _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 "$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. +_stats_render_month_section() { + local -ri total="$1"; shift + local -ra month_names=( + '' January February March April May June July August + September October November December ) + local -i month + local key + local -i max + + if (( ${#STATS_MONTHS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_MONTHS) + _stats_section_open 'Photos per month' + for (( month = 1; month <= 12; month++ )); do + key=$(printf '%02d' "$month") + if [ -z "${STATS_MONTHS[$key]:-}" ]; then + continue + fi + _stats_bar_row "${month_names[$month]}" \ + "${STATS_MONTHS[$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 "$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 "$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 "$total" \ + '50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \ + 'over 25600' + _stats_render_ordered_section 'Focal length' STATS_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 "$total" \ + 'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP' + _stats_render_ordered_section 'Aspect ratio' STATS_ASPECT "$total" \ + '3:2' '4:3' '16:9' '1:1' '5:4' 'other' + _stats_render_ordered_section 'Orientation' STATS_ORIENTATION "$total" \ + 'Landscape' 'Portrait' 'Square' + _stats_render_ordered_section 'File format' STATS_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 + + _stats_render_ranked_section 'Lenses' STATS_LENSES "$total" + _stats_render_ranked_section 'Exposure program' \ + STATS_EXPOSURE_PROGRAM "$total" + _stats_render_ranked_section 'Metering mode' STATS_METERING "$total" + _stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE "$total" + _stats_render_ranked_section 'Flash' STATS_FLASH "$total" +} + +# 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. +_stats_build_body() { + local -ri total="${STATS_TOTALS[photos]:-0}" + + 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" +} + +# Public render entry point (handoff for task rm0). Builds the body from the +# already-populated STATS_* globals and renders stats.html via the template +# engine, wrapping the body with the shared header/footer the way view/details +# pages do. Call collect_photo_exif_stats first to fill the globals. +# render_stats_page <html_dir> <backhref> [page_name] +# html_dir is the dist-relative output directory (top-level album: "."), +# backhref is the relative path back to the album root ("." for a top-level +# stats.html), and page_name defaults to "stats" -> stats.html. +render_stats_page() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_name="${1:-stats}" + local stats_body + + stats_body=$(_stats_build_body) + template header "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir '' \ + background_image '' \ + show_header_bar 'yes' + template stats "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + stats_body "$stats_body" + template footer "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name '' +} diff --git a/src/lib/template.source.sh b/src/lib/template.source.sh index c23b870..6d48ae5 100644 --- a/src/lib/template.source.sh +++ b/src/lib/template.source.sh @@ -144,7 +144,7 @@ current_date_text_to() { declare -ra TEMPLATE_RENDER_FIELD_SPECS=( 'render_animation_class_html|context_html|animation_class|animation_class|preview details view' 'render_backhref_css|context_css|backhref|backhref|header splash' - 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view' + 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view stats' 'render_background_image_css|context_css|background_image|background_image|header splash' 'render_blurs_dir_css|context_css|blurs_dir|blurs_dir|header splash' 'render_current_date_text|current_date_html|||' @@ -164,6 +164,7 @@ declare -ra TEMPLATE_RENDER_FIELD_SPECS=( 'render_preview_num_html|context_html|preview_num|preview_num|preview details view' 'render_redirect_page_html|context_html|redirect_page|redirect_page|redirect' 'render_show_header_bar|context_raw|show_header_bar|show_header_bar|header' + 'render_stats_body_html|context_raw|stats_body|stats_body|stats' 'render_tarball_include|tarball_include|||' 'render_tarball_name_html|context_html|tarball_name|tarball_name|footer' 'render_thumbheight_html|config_html|THUMBHEIGHT||' diff --git a/tests/cli.sh b/tests/cli.sh index 423bc49..48efc17 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -3700,6 +3700,7 @@ prev:html_dir prev preview:animation_class backhref html_dir page_num photo preview_num thumbs_dir redirect:html_dir redirect_page splash:backhref background_image blurs_dir enter_page html_dir photo photos_dir +stats:backhref html_dir stats_body view:animation_class backhref html_dir page_num photo photos_dir preview_num END ) @@ -3716,6 +3717,7 @@ declare -a template_names=( preview redirect splash + stats view ) @@ -3737,6 +3739,88 @@ BASH fi } +test_render_stats_page_renders_sections_and_escapes() { + local html + local output_file + + test::setup + output_file="$TEST_TMPDIR/dist/stats.html" + mkdir -p "$TEST_TMPDIR/dist" + + # Feed synthetic EXIF straight into accumulate_photo_stats so the test does + # not depend on the cache layer, then render. The second camera label carries + # & and < to prove EXIF-derived labels are HTML-escaped. + bash -euo pipefail -s \ + "$TEST_SHURIKEN" \ + "$TEST_REPO_ROOT/share/templates/default" \ + "$TEST_TMPDIR/dist" \ + <<'BASH' +shuriken="$1"; shift +template_dir="$1"; shift +dist_dir="$1"; shift + +# shellcheck source=/dev/null +source <(sed '$d' "$shuriken") + +DIST_DIR="$dist_dir" +TEMPLATE_DIR="$template_dir" +TITLE='Stats album' +HEIGHT=600 +THUMBHEIGHT=120 +MAXPREVIEWS=40 +ORIGINAL_BASEPATH='' +TARBALL_INCLUDE=no +SHURIKEN_OUTPUT_MODE=quiet +apply_config_defaults + +reset_photo_exif_stats +accumulate_photo_stats 'a.jpg' <<'EXIF' + Geometry: 6000x4000+0+0 + exif:Make: Canon + exif:Model: Canon EOS 5D + exif:FNumber: 28/10 + exif:ISOSpeedRatings: 400 + exif:DateTimeOriginal: 2021:06:14 10:00:00 +EXIF +accumulate_photo_stats 'b.jpg' <<'EXIF' + Geometry: 6000x4000+0+0 + exif:Make: Canon + exif:Model: Canon EOS 5D + exif:DateTimeOriginal: 2021:06:14 10:00:00 +EXIF +accumulate_photo_stats 'c.png' <<'EXIF' + Geometry: 4000x6000+0+0 + exif:Make: Nikon & Co + exif:Model: <Z6> + exif:DateTimeOriginal: 2022:07:14 10:00:00 +EXIF + +render_stats_page . . +BASH + + html=$(cat "$output_file") + + # Leaderboard entry links to camera-<slug>.html with the right count/percent. + test::assert_contains \ + '<a href="camera-canon-eos-5d.html">Canon EOS 5D</a>' "$html" + test::assert_contains '2 (67%)' "$html" + # A histogram section is present. + test::assert_contains '<h2>ISO</h2>' "$html" + test::assert_contains '400' "$html" + # EXIF-derived label with & and < is HTML-escaped, not raw markup. + test::assert_contains 'Nikon & Co <Z6>' "$html" + test::assert_not_contains 'Nikon & Co <Z6>' "$html" + # Total photo count drives the percentage denominator. + test::assert_contains '3 photos analysed.' "$html" + # The Stats nav link is wired into the shared header. + test::assert_contains '>Stats</a>' "$html" + # Empty categories are omitted (no flash/lens/shutter data was supplied). + test::assert_not_contains '<h2>Flash</h2>' "$html" + test::assert_not_contains '<h2>Lenses</h2>' "$html" + test::assert_not_contains '<h2>Shutter speed</h2>' "$html" + test::teardown +} + test_template_context_validator_fails_fast_without_errexit() { local output local -i status=0 @@ -5332,6 +5416,9 @@ main() { 'template required context vars come from render specs' \ test_template_required_context_vars_come_from_render_specs test::run_case \ + 'render_stats_page renders sections and escapes EXIF labels' \ + test_render_stats_page_renders_sections_and_escapes + test::run_case \ 'template context validator fails fast without errexit' \ test_template_context_validator_fails_fast_without_errexit test::run_case \ |
