diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-14 22:39:17 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-14 22:39:17 +0300 |
| commit | 0f0662850ad2df3502241cd1bdfcb366dd68d717 (patch) | |
| tree | 68ecdf0b67127ef6ce2fabca2c2ccefe8305de76 | |
| parent | cbfedd8f51d605ae8851b767ac21c00b877c33b7 (diff) | |
um0 add per-camera filter pages
Implement render_camera_pages <html_dir> <backhref> in stats.source.sh: one
camera-<slug>.html per camera in STATS_CAMERA_SLUGS, reusing the aggregation's
collision-resolved slugs and per-camera photo lists so filenames match pm0's
leaderboard links. Each page reuses the shared header/footer chrome plus a new
camera.tmpl that renders an HTML-escaped camera heading, a pre-built thumbnail
grid, and a back-to-stats link.
Thumbnails reuse preview.tmpl's markup style (thumb image from thumbs/) but link
to the full-size image under photos/ rather than a per-photo view page, since the
view-page name is not derivable outside the album pagination loop. The photos and
thumbs dir names are fixed to match generate()'s render_album_pages call.
Cameras are walked in LC_ALL=C label order for reproducible output, and cameras
with no recorded photos are skipped. Adds the camera_name/camera_thumbs render
field specs and a tests/cli.sh case covering per-camera files, slug collisions,
HTML escaping, thumbnail link targets, and determinism.
This provides the function + template + tests only; wiring into generate() and
the STATS_PAGE toggle remain for rm0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rwxr-xr-x | bin/shuriken | 127 | ||||
| -rw-r--r-- | share/templates/default/camera.tmpl | 24 | ||||
| -rw-r--r-- | src/lib/stats.source.sh | 123 | ||||
| -rw-r--r-- | src/lib/template.source.sh | 4 | ||||
| -rwxr-xr-x | tests/cli.sh | 105 |
5 files changed, 381 insertions, 2 deletions
diff --git a/bin/shuriken b/bin/shuriken index 63e0a7f..ec8e52b 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -606,9 +606,11 @@ 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 stats' + 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view stats camera' '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_camera_name_html|context_html|camera_name|camera_name|camera' + 'render_camera_thumbs_html|context_raw|camera_thumbs|camera_thumbs|camera' 'render_current_date_text|current_date_html|||' 'render_enter_page_html|context_html|enter_page|enter_page|splash' 'render_exif_details_html|context_raw|exif_details|exif_details|details' @@ -3577,6 +3579,129 @@ render_stats_page() { tarball_name '' } +# ---------------------------------------------------------------------------- +# Per-camera filter pages (task um0) +# ---------------------------------------------------------------------------- +# render_camera_pages turns the per-camera photo lists collected by the +# aggregation into one camera-<slug>.html page each, reusing the slugs the +# aggregation already stored in STATS_CAMERA_SLUGS/STATS_CAMERA_PHOTOS so the +# filenames match the camera-<slug>.html links pm0's leaderboard emits. Like the +# stats page, the variable-length markup (the thumbnail grid) is built here as a +# raw HTML string and handed to camera.tmpl through the camera_thumbs context +# field; camera.tmpl supplies only the page chrome (heading + back-to-stats +# link), wrapped by the shared header/footer the same way render_stats_page does. +# +# The album's incoming photo and thumbnail directories are the conventional +# 'photos'/'thumbs' that generate() passes to render_album_pages +# (render_album_pages 'photos' '.' 'thumbs' ...), so the grid reuses preview.tmpl's +# thumbnail markup style with those fixed dir names. Each thumbnail links to the +# full-size image under photos/ (backhref/photos/<file>) rather than a per-photo +# <page>-<preview>.html view page: a camera page is rendered outside the album +# pagination loop and cannot know which view page a given photo landed on, so the +# original image under photos/ is the only target derivable in this context (this +# choice is also documented in camera.tmpl). + +# Dist-relative subdirectories that hold the full-size images and thumbnails. +# These mirror the literals generate() passes to render_album_pages so camera +# pages link to the same assets the album pages do. +declare -gr STATS_CAMERA_PHOTOS_DIR='photos' +declare -gr STATS_CAMERA_THUMBS_DIR='thumbs' + +# Emit one thumbnail anchor for the camera grid: a thumb image (from thumbs/) +# wrapped in a link to the full-size image (from photos/), both resolved through +# the page's backhref. The photo filename is HTML-escaped because it ends up in +# both href and src attributes. Mirrors preview.tmpl's markup but targets the +# full image instead of a view page (see render_camera_pages header comment). +_stats_camera_thumbnail() { + local -r backhref_html="$1"; shift + local -r photo="$1"; shift + local photo_html + + photo_html=$(_html_escape "$photo") + printf ' <a href="%s/%s/%s">' \ + "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html" + printf '<img class="thumb" src="%s/%s/%s" /></a>\n' \ + "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" "$photo_html" +} + +# Build the full thumbnail grid for one camera from its newline-separated photo +# list. Photos keep aggregation order (the order they were encountered) so the +# grid is deterministic for a given input. Returns the grid HTML on stdout. +_stats_build_camera_thumbs() { + local -r backhref_html="$1"; shift + local -r photos="$1"; shift + local photo + + while IFS= read -r photo; do + if [ -n "$photo" ]; then + _stats_camera_thumbnail "$backhref_html" "$photo" + fi + done <<< "$photos" +} + +# Render a single camera-<slug>.html: header + camera.tmpl (escaped heading and +# pre-built thumbnail grid) + footer. The label is HTML-escaped via camera.tmpl's +# context_html field spec; backhref_html is the already-escaped path used inside +# the grid anchors so thumbnail links resolve from the page's location. +_stats_render_camera_page() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r label="$1"; shift + local -r slug="$1"; shift + local -r photos="$1"; shift + local backhref_html + local camera_thumbs + local -r page="camera-$slug.html" + + backhref_html=$(_html_escape "$backhref") + camera_thumbs=$(_stats_build_camera_thumbs "$backhref_html" "$photos") + template header "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir '' \ + background_image '' \ + show_header_bar 'yes' + template camera "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + camera_name "$label" \ + camera_thumbs "$camera_thumbs" + template footer "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name '' +} + +# Public render entry point (handoff for task rm0). Renders one +# camera-<slug>.html per camera in STATS_CAMERA_SLUGS, each showing a thumbnail +# grid of that camera's photos and a link back to the stats page. Call +# collect_photo_exif_stats first to fill the globals. +# render_camera_pages <html_dir> <backhref> +# 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 album), the +# same values rm0 passes to render_stats_page. Cameras are walked in LC_ALL=C +# label order (matching pm0's reproducible-output convention) and cameras with no +# recorded photos are skipped. +render_camera_pages() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local label + local slug + + if (( ${#STATS_CAMERA_SLUGS[@]} == 0 )); then + return + fi + while IFS= read -r label; do + slug="${STATS_CAMERA_SLUGS[$label]}" + if [ -z "${STATS_CAMERA_PHOTOS[$slug]:-}" ]; then + continue + fi + _stats_render_camera_page \ + "$html_dir" "$backhref" "$label" "$slug" \ + "${STATS_CAMERA_PHOTOS[$slug]}" + done < <(printf '%s\n' "${!STATS_CAMERA_SLUGS[@]}" | LC_ALL=C sort) +} + # Inlined from src/lib/config.source.sh existing_parent_dir() { local -r path="$1"; shift diff --git a/share/templates/default/camera.tmpl b/share/templates/default/camera.tmpl new file mode 100644 index 0000000..39ca273 --- /dev/null +++ b/share/templates/default/camera.tmpl @@ -0,0 +1,24 @@ +# Per-camera filter page body (task um0). Like stats.tmpl, all the variable-length +# markup -- here the thumbnail grid of every photo shot with this camera -- is +# pre-rendered by render_camera_pages in src/lib/stats.source.sh and handed in +# through the raw context field render_camera_thumbs_html, so this template only +# supplies the page chrome: the heading (the HTML-escaped camera name), the +# pre-built grid, and a link back to the stats page. The surrounding +# <html>/<head>/<body> and footer come from the header/footer templates, which +# render_camera_pages emits around this one (same pattern as stats/view/details). +# +# Thumbnails link to the full-size image under photos/ rather than to a per-photo +# view page: a camera page is built outside the album's pagination loop and so +# cannot know which <page>-<preview>.html a given photo landed on. Linking to the +# original image is the sensible derivable target (see render_camera_pages). +cat <<END +<div class="view stats camera"> + <h1 class="stats-title">${render_title_html} — ${render_camera_name_html}</h1> + <div class="stats-back navigator"> + <a href="${render_backhref_html}/stats.html">Back to stats</a> + </div> + <div class="camera-thumbs"> +${render_camera_thumbs_html} + </div> +</div> +END diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh index 72b2d26..b0a1646 100644 --- a/src/lib/stats.source.sh +++ b/src/lib/stats.source.sh @@ -846,3 +846,126 @@ render_stats_page() { backhref "$backhref" \ tarball_name '' } + +# ---------------------------------------------------------------------------- +# Per-camera filter pages (task um0) +# ---------------------------------------------------------------------------- +# render_camera_pages turns the per-camera photo lists collected by the +# aggregation into one camera-<slug>.html page each, reusing the slugs the +# aggregation already stored in STATS_CAMERA_SLUGS/STATS_CAMERA_PHOTOS so the +# filenames match the camera-<slug>.html links pm0's leaderboard emits. Like the +# stats page, the variable-length markup (the thumbnail grid) is built here as a +# raw HTML string and handed to camera.tmpl through the camera_thumbs context +# field; camera.tmpl supplies only the page chrome (heading + back-to-stats +# link), wrapped by the shared header/footer the same way render_stats_page does. +# +# The album's incoming photo and thumbnail directories are the conventional +# 'photos'/'thumbs' that generate() passes to render_album_pages +# (render_album_pages 'photos' '.' 'thumbs' ...), so the grid reuses preview.tmpl's +# thumbnail markup style with those fixed dir names. Each thumbnail links to the +# full-size image under photos/ (backhref/photos/<file>) rather than a per-photo +# <page>-<preview>.html view page: a camera page is rendered outside the album +# pagination loop and cannot know which view page a given photo landed on, so the +# original image under photos/ is the only target derivable in this context (this +# choice is also documented in camera.tmpl). + +# Dist-relative subdirectories that hold the full-size images and thumbnails. +# These mirror the literals generate() passes to render_album_pages so camera +# pages link to the same assets the album pages do. +declare -gr STATS_CAMERA_PHOTOS_DIR='photos' +declare -gr STATS_CAMERA_THUMBS_DIR='thumbs' + +# Emit one thumbnail anchor for the camera grid: a thumb image (from thumbs/) +# wrapped in a link to the full-size image (from photos/), both resolved through +# the page's backhref. The photo filename is HTML-escaped because it ends up in +# both href and src attributes. Mirrors preview.tmpl's markup but targets the +# full image instead of a view page (see render_camera_pages header comment). +_stats_camera_thumbnail() { + local -r backhref_html="$1"; shift + local -r photo="$1"; shift + local photo_html + + photo_html=$(_html_escape "$photo") + printf ' <a href="%s/%s/%s">' \ + "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html" + printf '<img class="thumb" src="%s/%s/%s" /></a>\n' \ + "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" "$photo_html" +} + +# Build the full thumbnail grid for one camera from its newline-separated photo +# list. Photos keep aggregation order (the order they were encountered) so the +# grid is deterministic for a given input. Returns the grid HTML on stdout. +_stats_build_camera_thumbs() { + local -r backhref_html="$1"; shift + local -r photos="$1"; shift + local photo + + while IFS= read -r photo; do + if [ -n "$photo" ]; then + _stats_camera_thumbnail "$backhref_html" "$photo" + fi + done <<< "$photos" +} + +# Render a single camera-<slug>.html: header + camera.tmpl (escaped heading and +# pre-built thumbnail grid) + footer. The label is HTML-escaped via camera.tmpl's +# context_html field spec; backhref_html is the already-escaped path used inside +# the grid anchors so thumbnail links resolve from the page's location. +_stats_render_camera_page() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r label="$1"; shift + local -r slug="$1"; shift + local -r photos="$1"; shift + local backhref_html + local camera_thumbs + local -r page="camera-$slug.html" + + backhref_html=$(_html_escape "$backhref") + camera_thumbs=$(_stats_build_camera_thumbs "$backhref_html" "$photos") + template header "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir '' \ + background_image '' \ + show_header_bar 'yes' + template camera "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + camera_name "$label" \ + camera_thumbs "$camera_thumbs" + template footer "$page" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name '' +} + +# Public render entry point (handoff for task rm0). Renders one +# camera-<slug>.html per camera in STATS_CAMERA_SLUGS, each showing a thumbnail +# grid of that camera's photos and a link back to the stats page. Call +# collect_photo_exif_stats first to fill the globals. +# render_camera_pages <html_dir> <backhref> +# 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 album), the +# same values rm0 passes to render_stats_page. Cameras are walked in LC_ALL=C +# label order (matching pm0's reproducible-output convention) and cameras with no +# recorded photos are skipped. +render_camera_pages() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local label + local slug + + if (( ${#STATS_CAMERA_SLUGS[@]} == 0 )); then + return + fi + while IFS= read -r label; do + slug="${STATS_CAMERA_SLUGS[$label]}" + if [ -z "${STATS_CAMERA_PHOTOS[$slug]:-}" ]; then + continue + fi + _stats_render_camera_page \ + "$html_dir" "$backhref" "$label" "$slug" \ + "${STATS_CAMERA_PHOTOS[$slug]}" + done < <(printf '%s\n' "${!STATS_CAMERA_SLUGS[@]}" | LC_ALL=C sort) +} diff --git a/src/lib/template.source.sh b/src/lib/template.source.sh index 6d48ae5..9fd3ad4 100644 --- a/src/lib/template.source.sh +++ b/src/lib/template.source.sh @@ -144,9 +144,11 @@ 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 stats' + 'render_backhref_html|context_html|backhref|backhref|footer header preview splash details view stats camera' '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_camera_name_html|context_html|camera_name|camera_name|camera' + 'render_camera_thumbs_html|context_raw|camera_thumbs|camera_thumbs|camera' 'render_current_date_text|current_date_html|||' 'render_enter_page_html|context_html|enter_page|enter_page|splash' 'render_exif_details_html|context_raw|exif_details|exif_details|details' diff --git a/tests/cli.sh b/tests/cli.sh index 48efc17..50a61f3 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -3821,6 +3821,108 @@ BASH test::teardown } +test_render_camera_pages_renders_one_page_per_camera() { + local html + local html_again + local dist_dir + + test::setup + dist_dir="$TEST_TMPDIR/dist" + mkdir -p "$dist_dir" + + # Feed synthetic EXIF straight into accumulate_photo_stats (same approach as + # the stats-page test) to exercise three cameras: one whose label needs + # HTML-escaping (& and <), and a slug-collision pair ("Canon EOS 5D" vs + # "Canon EOS-5D" both slug to canon-eos-5d, so the second gets -2). Then + # render the per-camera pages twice into separate dirs to assert determinism. + bash -euo pipefail -s \ + "$TEST_SHURIKEN" \ + "$TEST_REPO_ROOT/share/templates/default" \ + "$dist_dir" \ + <<'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 + +feed() { + reset_photo_exif_stats + accumulate_photo_stats 'a.jpg' <<'EXIF' + Geometry: 6000x4000+0+0 + exif:Make: Canon + exif:Model: Canon EOS 5D +EXIF + accumulate_photo_stats 'b.jpg' <<'EXIF' + Geometry: 6000x4000+0+0 + exif:Make: Canon + exif:Model: Canon EOS 5D +EXIF + accumulate_photo_stats 'c.png' <<'EXIF' + Geometry: 4000x6000+0+0 + exif:Make: Nikon & Co + exif:Model: <Z6> +EXIF + accumulate_photo_stats 'd.jpg' <<'EXIF' + Geometry: 6000x4000+0+0 + exif:Make: Canon + exif:Model: Canon EOS-5D +EXIF +} + +mkdir -p "$dist_dir/run1" "$dist_dir/run2" +feed +render_camera_pages run1 .. +feed +render_camera_pages run2 .. +BASH + + # One page per camera, named with the slugs the aggregation stored. The + # colliding labels get distinct files (canon-eos-5d and canon-eos-5d-2). + test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d.html" + test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d-2.html" + test::assert_file_exists "$dist_dir/run1/camera-nikon-co-z6.html" + + # The Canon EOS 5D page lists exactly its two photos as thumbnails linking to + # the full images under photos/, and not the other camera's photo. + html=$(cat "$dist_dir/run1/camera-canon-eos-5d.html") + test::assert_contains '<a href="../photos/a.jpg">' "$html" + test::assert_contains '<img class="thumb" src="../thumbs/a.jpg" />' "$html" + test::assert_contains '<a href="../photos/b.jpg">' "$html" + test::assert_not_contains 'photos/c.png' "$html" + test::assert_not_contains 'photos/d.jpg' "$html" + # Heading shows the (trusted) camera label and a back-to-stats link. + test::assert_contains 'Canon EOS 5D' "$html" + test::assert_contains '<a href="../stats.html">Back to stats</a>' "$html" + + # The EXIF-derived label with & and < is HTML-escaped in the heading. + html=$(cat "$dist_dir/run1/camera-nikon-co-z6.html") + test::assert_contains 'Nikon & Co <Z6>' "$html" + test::assert_not_contains 'Nikon & Co <Z6>' "$html" + test::assert_contains '<a href="../photos/c.png">' "$html" + + # Output is deterministic: the second run is byte-identical to the first. + html=$(cat "$dist_dir/run1/camera-canon-eos-5d.html") + html_again=$(cat "$dist_dir/run2/camera-canon-eos-5d.html") + if [ "$html" != "$html_again" ]; then + printf 'FAIL: camera page not reproducible across runs\n' >&2 + exit 1 + fi + test::teardown +} + test_template_context_validator_fails_fast_without_errexit() { local output local -i status=0 @@ -5419,6 +5521,9 @@ main() { 'render_stats_page renders sections and escapes EXIF labels' \ test_render_stats_page_renders_sections_and_escapes test::run_case \ + 'render_camera_pages renders one page per camera' \ + test_render_camera_pages_renders_one_page_per_camera + test::run_case \ 'template context validator fails fast without errexit' \ test_template_context_validator_fails_fast_without_errexit test::run_case \ |
