Add a right-click copy link and share menu on gallery photos with stable per-photo permalinks
CI / build-and-scan (pull_request) Successful in 2m9s

This commit is contained in:
2026-09-23 15:46:35 +02:00
parent 6c0a169528
commit 88754bf907
8 changed files with 610 additions and 1 deletions
+49
View File
@@ -39,6 +39,55 @@ def test_render_gallery_images_with_and_without_tags():
assert 'alt=""' in html
def test_render_gallery_images_adds_photo_id_when_present():
html = hg.render_gallery_images(
[{"src": "gallery/a.jpg", "photo_id": "abc123"}, {"src": "gallery/b.jpg"}]
)
assert html.count("data-photo-id=") == 1
assert 'data-photo-id="abc123"' in html
def _photo_page(**overrides):
kwargs = {
"photo_id": "ab12",
"site_title": "My photos",
"description": "A gallery",
"alt": "Sunset",
"page_url": "https://example.com/photo/ab12/",
"image_url": "https://example.com/img/share/ab12.jpg",
"image_size": (1200, 800),
"signature": "<!-- sig -->",
}
kwargs.update(overrides)
return hg.render_photo_page(**kwargs)
def test_render_photo_page_preview_tags_and_noindex():
page = _photo_page()
assert '<meta name="robots" content="noindex, follow">' in page
assert '<meta property="og:image" content="https://example.com/img/share/ab12.jpg" />' in page
assert '<meta property="og:url" content="https://example.com/photo/ab12/" />' in page
assert '<meta property="og:image:width" content="1200" />' in page
assert "<title>Sunset - My photos</title>" in page
assert 'rel="canonical"' not in page
def test_render_photo_page_redirects_to_gallery():
page = _photo_page()
assert 'location.replace("/?photo=ab12")' in page
assert '<a href="/?photo=ab12">' in page
def test_render_photo_page_escapes_text():
page = _photo_page(alt="", site_title='"><script>alert(1)</script>')
assert "<script>alert(1)" not in page
assert "<title>&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;</title>" in page
def test_generate_gallery_json_from_images(tmp_path):
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
+76
View File
@@ -135,3 +135,79 @@ def test_generate_favicon_ico_no_favicon_skips(tmp_path):
output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({}, tmp_path, output_path)
assert not output_path.exists()
def test_compute_photo_ids_hashes_original_files(tmp_path, make_image):
import hashlib
img_dir = tmp_path / "photos"
a = make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
make_image(img_dir / "gallery" / "b.jpg", color=(200, 10, 10))
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}, {"src": "gallery/missing.jpg"}]
ip.compute_photo_ids(images, img_dir)
assert images[0]["photo_id"] == hashlib.sha256(a.read_bytes()).hexdigest()
assert len(images[1]["photo_id"]) == 64
assert images[0]["photo_id"] != images[1]["photo_id"]
assert "photo_id" not in images[2]
def test_compute_photo_ids_is_independent_of_order_and_name(tmp_path, make_image):
img_dir = tmp_path / "photos"
make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
(img_dir / "gallery" / "renamed.jpg").write_bytes((img_dir / "gallery" / "a.jpg").read_bytes())
make_image(img_dir / "gallery" / "b.jpg", color=(9, 9, 9))
first = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}]
second = [{"src": "gallery/b.jpg"}, {"src": "gallery/renamed.jpg"}]
ip.compute_photo_ids(first, img_dir)
ip.compute_photo_ids(second, img_dir)
assert first[0]["photo_id"] == second[1]["photo_id"]
assert first[1]["photo_id"] == second[0]["photo_id"]
def test_compute_photo_ids_suffixes_duplicates(tmp_path, make_image):
img_dir = tmp_path / "photos"
make_image(img_dir / "gallery" / "a.jpg")
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}]
ip.compute_photo_ids(images, img_dir)
base = images[0]["photo_id"]
assert images[1]["photo_id"] == f"{base}-2"
assert images[2]["photo_id"] == f"{base}-3"
def test_generate_share_preview_resizes_to_jpeg(tmp_path, make_image):
input_path = make_image(tmp_path / "in.png", size=(2400, 1600), fmt="PNG")
output_path = tmp_path / "share" / "x.jpg"
size = ip.generate_share_preview(input_path, output_path)
assert size == (1200, 800)
with Image.open(output_path) as img:
assert img.format == "JPEG"
assert img.size == (1200, 800)
def test_generate_share_preview_converts_embedded_profile_to_srgb(tmp_path):
from PIL import ImageCms
input_path = tmp_path / "in.jpg"
srgb_bytes = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
Image.new("RGB", (300, 200), (10, 200, 30)).save(input_path, "JPEG", icc_profile=srgb_bytes)
output_path = tmp_path / "out.jpg"
size = ip.generate_share_preview(input_path, output_path)
assert size == (300, 200)
with Image.open(output_path) as img:
assert img.mode == "RGB"
def test_generate_share_preview_failure_returns_none(tmp_path):
bad = tmp_path / "bad.jpg"
bad.write_bytes(b"not an image")
assert ip.generate_share_preview(bad, tmp_path / "out.jpg") is None
+29
View File
@@ -36,6 +36,35 @@ def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
assert all(p.suffix in (".webp", ".jpg") for p in processed)
def test_build_generates_photo_share_pages(tmp_path, monkeypatch, demo_root):
import hashlib
import re
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml")
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
gallery = yaml.safe_load((demo_root / "gallery.yaml").read_text(encoding="utf-8"))
sources = [img["src"] for img in gallery["gallery"]["images"]]
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
ids = re.findall(r'data-photo-id="([^"]+)"', index_html)
assert len(ids) == len(sources)
first_hash = hashlib.sha256((demo_root / "photos" / sources[0]).read_bytes()).hexdigest()
assert ids[0] == first_hash
page = (output_dir / "photo" / first_hash / "index.html").read_text(encoding="utf-8")
assert f'content="https://lumeex.djeex.fr/img/share/{first_hash}.jpg"' in page
assert 'content="noindex, follow"' in page
assert (output_dir / "img" / "share" / f"{first_hash}.jpg").exists()
assert len(list((output_dir / "photo").iterdir())) == len(set(ids))
assert "/photo/" not in (output_dir / "sitemap.xml").read_text(encoding="utf-8")
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
site_data["build"]["convert_images"] = False