Organize gallery into highlighted photo groups

This commit is contained in:
2026-09-19 18:13:48 +03:30
parent 3ec6cdd3ec
commit b6b19c07bb
30 changed files with 335 additions and 66 deletions
+1 -1
View File
@@ -3,8 +3,8 @@ layout: post
title: x_DSC-0999.jpg title: x_DSC-0999.jpg
date: 2024-10-09 date: 2024-10-09
categories: art categories: art
anchor: "Kazakhstan - World Nomad Games"
--- ---
![x](/img/arts/kazakhstan-2024/x_DSC-0999.jpg) ![x](/img/arts/kazakhstan-2024/x_DSC-0999.jpg)
@@ -3,6 +3,7 @@ layout: post
title: Taq-e-Bostan_000521270020.jpg title: Taq-e-Bostan_000521270020.jpg
date: 2024-10-25 date: 2024-10-25
categories: art categories: art
anchor: "Iran - Hamadan, Kermanshah, Tabriz"
--- ---
![Taq e Bostan](/img/arts/iran-analog-2024/Taq-e-Bostan_000521270020.jpg) ![Taq e Bostan](/img/arts/iran-analog-2024/Taq-e-Bostan_000521270020.jpg)
@@ -1,7 +1,7 @@
--- ---
layout: post layout: post
title: "photo-260cc99527c13f256d4ea39d0959f5daa942c888ea882852e73fd94f6c30b7db" title: "photo-260cc99527c13f256d4ea39d0959f5daa942c888ea882852e73fd94f6c30b7db"
date: "2026-09-19T12:10:40.964254+00:00" date: "2026-09-19T12:29:32.999986+00:00"
categories: art categories: art
--- ---
@@ -1,7 +1,7 @@
--- ---
layout: post layout: post
title: "photo-474ab3283ce8801ac6e59b67b65b4169167053873a5a8db4a15b840a3448266c" title: "photo-474ab3283ce8801ac6e59b67b65b4169167053873a5a8db4a15b840a3448266c"
date: "2026-09-19T12:29:33.758101+00:00" date: "2026-09-19T12:29:32.999994+00:00"
categories: art categories: art
--- ---
@@ -0,0 +1,9 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-00"
date: "2026-09-19T12:29:32.999993+00:00"
categories: art
anchor: "Romania"
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/00.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-01"
date: "2026-09-19T12:29:32.999992+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/01.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-02"
date: "2026-09-19T12:29:32.999984+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/02.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-03"
date: "2026-09-19T12:29:32.999983+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/03.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-04"
date: "2026-09-19T12:29:32.999995+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/04.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-05"
date: "2026-09-19T12:29:32.999996+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/05.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-06"
date: "2026-09-19T12:29:32.999997+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/06.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,9 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-07"
date: "2026-09-19T12:29:32.999998+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/07.jpg" alt="Transmission of Flesh by Erfan Ashourioun" loading="lazy"></p>
<span class="image-details">Transmission of Flesh by Erfan Ashourioun</span>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d-08"
date: "2026-09-19T12:29:32.999999+00:00"
categories: art
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/08.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,9 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d"
date: "2026-09-19T12:29:33+00:00"
categories: art
anchor: "Armenia"
---
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/09.jpg" alt="" loading="lazy"></p>
@@ -1,19 +0,0 @@
---
layout: post
title: "photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d"
date: "2026-09-19T12:27:27.038898+00:00"
categories: art
---
<div id="photo-7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d"></div>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/00.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/01.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/02.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/03.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/04.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/05.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/06.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/07.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/08.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d/09.jpg" alt="" loading="lazy"></p>
@@ -1,7 +1,7 @@
--- ---
layout: post layout: post
title: "photo-b18a2f1c7127077c801160782012cb164759f0b9dd41762205b45e4c9a07d673" title: "photo-b18a2f1c7127077c801160782012cb164759f0b9dd41762205b45e4c9a07d673"
date: "2026-09-19T12:03:11.135424+00:00" date: "2026-09-19T12:29:32.999985+00:00"
categories: art categories: art
--- ---
@@ -0,0 +1,10 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b"
date: "2026-09-19T12:29:32.999991+00:00"
categories: art
---
<div id="photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b"></div>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/00.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b-01"
date: "2026-09-19T12:29:32.999990+00:00"
categories: art
---
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/01.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b-02"
date: "2026-09-19T12:29:32.999989+00:00"
categories: art
---
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/02.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b-03"
date: "2026-09-19T12:29:32.999988+00:00"
categories: art
---
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/03.jpg" alt="" loading="lazy"></p>
@@ -0,0 +1,8 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b-04"
date: "2026-09-19T12:29:32.999987+00:00"
categories: art
---
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/04.jpg" alt="" loading="lazy"></p>
@@ -1,14 +0,0 @@
---
layout: post
title: "photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b"
date: "2026-09-19T12:26:31.454056+00:00"
categories: art
---
<div id="photo-c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b"></div>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/00.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/01.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/02.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/03.jpg" alt="" loading="lazy"></p>
<p><img src="/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/04.jpg" alt="" loading="lazy"></p>
+7 -4
View File
@@ -150,10 +150,13 @@ Create **Publish to blog**:
only `published` means the deployed site's receipt contains this album. only `published` means the deployed site's receipt contains this album.
The ZIP route accepts 1–20 images in archive-entry order and keeps them as one The ZIP route accepts 1–20 images in archive-entry order and keeps them as one
upload. Each photo renders as its own gallery `<li>`, including photos in older upload job. Each photo is stored as its own `_art` document and renders as its
batches. The caption appears on each photo; the first image is the highlight target. Metadata sidecars own gallery `<li>`. The shared caption is repeated on each photo and an optional
under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API clients can also send highlight begins on the first photo. Photos remain contiguous in the selection order.
repeated `photos` parts; send either `archive` or `photos`, never both. Older batch documents are still split at render time for backward compatibility.
Metadata sidecars under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API
clients can also send repeated `photos` parts; send either `archive` or `photos`,
never both.
Select still photos; videos/Live Photo video components aren't supported. Dates Select still photos; videos/Live Photo video components aren't supported. Dates
are upload time in UTC; captions are plain text. A caption is optional. are upload time in UTC; captions are plain text. A caption is optional.
An optional highlight name (up to 200 characters) adds one link to **Highlights**, An optional highlight name (up to 200 characters) adds one link to **Highlights**,
+29 -15
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import re import re
import shutil import shutil
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import warnings import warnings
import zipfile import zipfile
@@ -79,6 +79,26 @@ def web_image(raw, destination):
Image.Resampling.LANCZOS) Image.Resampling.LANCZOS)
def photo_entry(job, date, index, count, caption, highlight):
# Give each photo its own collection document. Descending dates preserve the
# selection order because the gallery renders the newest document first.
item_date = datetime.fromisoformat(date) + timedelta(microseconds=count - index)
title = f"photo-{job}" if index == 0 else f"photo-{job}-{index:02}"
content = ["---", "layout: post", f'title: "{title}"',
f'date: "{item_date.isoformat()}"', "categories: art"]
if index == 0 and highlight:
content.append(f"anchor: {json.dumps(highlight)}")
content.extend(["---", ""])
if index == 0 and not highlight:
content.extend([f'<div id="photo-{job}"></div>', ""])
content.append(f'<p><img src="/img/arts/uploads/{job}/{index:02}.jpg" '
f'alt="{public_text(caption)}" loading="lazy"></p>')
if caption:
content.append('<span class="image-details">' +
public_text(caption).replace("\n", "<br>") + "</span>")
return "\n".join(content) + "\n"
def create_app(data_dir=None, token=None): def create_app(data_dir=None, token=None):
app = Flask(__name__) app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=100 * 1024 * 1024, app.config.update(MAX_CONTENT_LENGTH=100 * 1024 * 1024,
@@ -261,21 +281,15 @@ def create_app(data_dir=None, token=None):
if kind == "snippet": if kind == "snippet":
manifest.update(kind=kind, text=text) manifest.update(kind=kind, text=text)
atomic_json(temporary / "manifest.json", manifest) atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"', if kind == "photo":
f'date: "{date}"', "categories: art"] (temporary / "entries").mkdir()
if highlight:
content.append(f"anchor: {json.dumps(highlight)}")
content.extend(["---", ""])
# The gallery already renders an anchor before the first photo for highlights.
if not highlight:
content.extend([f'<div id="photo-{job}"></div>', ""])
for index in range(len(photos)): for index in range(len(photos)):
content.append(f'<p><img src="/img/arts/uploads/{job}/{index:02}.jpg" ' (temporary / "entries" / f"{index:02}.md").write_text(
f'alt="{public_text(caption)}" loading="lazy"></p>') photo_entry(job, date, index, len(photos), caption, highlight))
if caption: # Retain the old filename for compatibility with publisher versions
content.append('<span class="image-details">' + # that predate one-document-per-photo submissions.
public_text(caption).replace("\n", "<br>") + "</span>") content = [(temporary / "entries/00.md").read_text()]
if kind == "snippet": else:
content = [f'<div id="snippet-{job}"></div>', ""] content = [f'<div id="snippet-{job}"></div>', ""]
for paragraph in (text, caption): for paragraph in (text, caption):
if paragraph: if paragraph:
+12 -1
View File
@@ -86,9 +86,20 @@ class Publisher:
entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md" entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md"
images = Path("img/arts/uploads") / job images = Path("img/arts/uploads") / job
(self.repo / entry).parent.mkdir(parents=True, exist_ok=True) (self.repo / entry).parent.mkdir(parents=True, exist_ok=True)
entries = submission / "entries"
if entries.is_dir():
# Remove the former batch document when a queued submission
# is republished by a newer service version.
self.git("rm", "--ignore-unmatch", "--", str(entry))
for source in sorted(entries.glob("*.md")):
destination = Path("_art") / f"{manifest['date'][:10]}-photo-{job}-{source.stem}.md"
shutil.copyfile(source, self.repo / destination)
self.git("add", "--", str(destination))
else:
shutil.copyfile(submission / "entry.md", self.repo / entry) shutil.copyfile(submission / "entry.md", self.repo / entry)
self.git("add", "--", str(entry))
shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True) shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True)
self.git("add", "--", str(entry), str(images)) self.git("add", "--", str(images))
if self.git("diff", "--cached", "--name-only"): if self.git("diff", "--cached", "--name-only"):
self.git("commit", "-m", f"Publish {len(jobs)} submission(s)") self.git("commit", "-m", f"Publish {len(jobs)} submission(s)")
self.git("push", "origin", f"HEAD:refs/heads/{self.branch}") self.git("push", "origin", f"HEAD:refs/heads/{self.branch}")
+11 -6
View File
@@ -116,10 +116,12 @@ def test_album_caption_order_and_retry_deduplication(service):
assert response.status_code == 202 assert response.status_code == 202
job = response.json["id"] job = response.json["id"]
folder = data / "submissions" / job folder = data / "submissions" / job
entry = (folder / "entry.md").read_text() entries = sorted((folder / "entries").glob("*.md"))
assert "<script>" not in entry and "{%" not in entry and "{{" not in entry assert len(entries) == 2
assert "<br>second line" in entry rendered = "\n".join(entry.read_text() for entry in entries)
assert entry.index("00.jpg") < entry.index("01.jpg") assert "<script>" not in rendered and "{%" not in rendered and "{{" not in rendered
assert rendered.count("<br>second line") == 2
assert "00.jpg" in entries[0].read_text() and "01.jpg" in entries[1].read_text()
assert Image.open(folder / "images/00.jpg").getpixel((0, 0))[0] > 240 assert Image.open(folder / "images/00.jpg").getpixel((0, 0))[0] > 240
assert Image.open(folder / "images/01.jpg").getpixel((0, 0))[2] > 240 assert Image.open(folder / "images/01.jpg").getpixel((0, 0))[2] > 240
assert (folder / "originals/0").read_bytes() == images[0] assert (folder / "originals/0").read_bytes() == images[0]
@@ -168,7 +170,9 @@ def test_highlight_album_and_safe_front_matter(service):
assert response.status_code == 202 assert response.status_code == 202
job = response.json["id"] job = response.json["id"]
folder = data / "submissions" / job folder = data / "submissions" / job
entry = (folder / "entry.md").read_text() entries = sorted((folder / "entries").glob("*.md"))
assert len(entries) == 2
entry = entries[0].read_text()
metadata, body = entry.split("---\n", 2)[1:] metadata, body = entry.split("---\n", 2)[1:]
anchor = next(line for line in metadata.splitlines() if line.startswith("anchor: ")) anchor = next(line for line in metadata.splitlines() if line.startswith("anchor: "))
assert json.loads(anchor.removeprefix("anchor: ")) == name assert json.loads(anchor.removeprefix("anchor: ")) == name
@@ -176,7 +180,8 @@ def test_highlight_album_and_safe_front_matter(service):
# The template emits the sole target at the start of this album, before 00.jpg. # The template emits the sole target at the start of this album, before 00.jpg.
assert f'title: "photo-{job}"' in metadata assert f'title: "photo-{job}"' in metadata
assert f'id="photo-{job}"' not in body assert f'id="photo-{job}"' not in body
assert body.index("00.jpg") < body.index("01.jpg") assert "00.jpg" in body and "01.jpg" in entries[1].read_text()
assert "anchor:" not in entries[1].read_text()
assert json.loads((folder / "manifest.json").read_text())["highlight"] == name assert json.loads((folder / "manifest.json").read_text())["highlight"] == name
assert upload(client, images, "Album caption", highlight=name).status_code == 200 assert upload(client, images, "Album caption", highlight=name).status_code == 200
assert upload(client, images, "Album caption", highlight="Other trip").json["id"] != job assert upload(client, images, "Album caption", highlight="Other trip").json["id"] != job
+61
View File
@@ -0,0 +1,61 @@
"""One-time migration of September 2026 uploads into individual highlighted photos."""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import html
ART = Path("_art")
UPLOAD = "/img/arts/uploads"
OLD = [
"260cc99527c13f256d4ea39d0959f5daa942c888ea882852e73fd94f6c30b7db",
"474ab3283ce8801ac6e59b67b65b4169167053873a5a8db4a15b840a3448266c",
"7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d",
"b18a2f1c7127077c801160782012cb164759f0b9dd41762205b45e4c9a07d673",
"c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b",
]
armenia = [
(OLD[2], 9), (OLD[2], 8), (OLD[2], 7), (OLD[2], 6),
(OLD[2], 5), (OLD[2], 4), (OLD[1], 0),
]
romania = [
(OLD[2], 0), (OLD[2], 1), (OLD[4], 0), (OLD[4], 1),
(OLD[4], 2), (OLD[4], 3), (OLD[4], 4), (OLD[0], 0), (OLD[3], 0),
]
ungrouped = [(OLD[2], 2), (OLD[2], 3)]
ordered = armenia + romania + ungrouped
assert len(ordered) == len(set(ordered)) == 18
anchors = {armenia[0]: "Armenia", romania[0]: "Romania"}
captions = {(OLD[2], 7): "Transmission of Flesh by Erfan Ashourioun"}
first_for_job = {}
for photo in ordered:
first_for_job.setdefault(photo[0], photo)
for job in OLD:
batch = next(ART.glob(f"*-photo-{job}.md"), None)
if batch:
batch.unlink()
start = datetime(2026, 9, 19, 12, 29, 33, tzinfo=timezone.utc)
for position, (job, index) in enumerate(ordered):
title = f"photo-{job}-{index:02}"
if (job, index) == first_for_job[job]:
title = f"photo-{job}"
date = start - timedelta(microseconds=position)
lines = ["---", "layout: post", f'title: "{title}"',
f'date: "{date.isoformat()}"', "categories: art"]
if (job, index) in anchors:
lines.append(f'anchor: "{anchors[(job, index)]}"')
lines.extend(["---", ""])
if (job, index) == first_for_job[job] and (job, index) not in anchors:
lines.extend([f'<div id="photo-{job}"></div>', ""])
caption = captions.get((job, index), "")
lines.append(f'<p><img src="{UPLOAD}/{job}/{index:02}.jpg" '
f'alt="{html.escape(caption, quote=True)}" loading="lazy"></p>')
if caption:
lines.append(f'<span class="image-details">{html.escape(caption)}</span>')
target = ART / f"2026-09-19-photo-{job}-{index:02}.md"
target.write_text("\n".join(lines) + "\n")
print("Created 18 individual photo documents in Armenia, Romania, and ungrouped order")
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from pathlib import Path from pathlib import Path
import re import re
photos = sorted(set(re.findall(r'id="photo-([a-f0-9]{64})"', Path('_site/art/index.html').read_text()))) photos = sorted(set(re.findall(r'id=["\']photo-([a-f0-9]{64})["\']', Path('_site/art/index.html').read_text())))
Path('_site/photo-publication.json').write_text(json.dumps({ Path('_site/photo-publication.json').write_text(json.dumps({
'commit': os.environ['GITHUB_SHA'], 'photos': photos, 'commit': os.environ['GITHUB_SHA'], 'photos': photos,
'snippets': sorted(set(re.findall(r'id="snippet-([a-f0-9]{64})"', 'snippets': sorted(set(re.findall(r'id="snippet-([a-f0-9]{64})"',
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[2]
ART = ROOT / "_art"
JOB = "7c832afa1b73023d8f2391ff40c221be9ffd468a1a96a6bf4900cbf95595d31d"
ARMENIA = [f"/img/arts/uploads/{JOB}/{index:02}.jpg" for index in (9, 8, 7, 6, 5, 4)] + [
"/img/arts/uploads/474ab3283ce8801ac6e59b67b65b4169167053873a5a8db4a15b840a3448266c/00.jpg"]
ROMANIA = [f"/img/arts/uploads/{JOB}/{index:02}.jpg" for index in (0, 1)] + [
f"/img/arts/uploads/c53f20236f166632bff163339c8e789cc09b0d7e63c5b531b1fc20705460975b/{index:02}.jpg"
for index in range(5)] + [
"/img/arts/uploads/260cc99527c13f256d4ea39d0959f5daa942c888ea882852e73fd94f6c30b7db/00.jpg",
"/img/arts/uploads/b18a2f1c7127077c801160782012cb164759f0b9dd41762205b45e4c9a07d673/00.jpg"]
class PhotoHighlightTests(unittest.TestCase):
def test_uploaded_photos_are_individual_and_grouped(self):
records = []
for path in ART.glob("*photo-*.md"):
content = path.read_text()
images = re.findall(r'<img src="([^"]+/uploads/[^"]+)"', content)
self.assertEqual(len(images), 1, path)
date = re.search(r'^date: "([^"]+)"', content, re.MULTILINE)
anchor = re.search(r'^anchor: "([^"]+)"', content, re.MULTILINE)
records.append((datetime.fromisoformat(date.group(1)), images[0],
anchor.group(1) if anchor else None, content))
records.sort(reverse=True)
grouped = [record for record in records if record[1] in ARMENIA + ROMANIA]
self.assertEqual([record[1] for record in grouped], ARMENIA + ROMANIA)
self.assertEqual(grouped[0][2], "Armenia")
self.assertEqual(grouped[7][2], "Romania")
self.assertIn("Transmission of Flesh by Erfan Ashourioun", grouped[2][3])
def test_existing_collection_highlight_starts(self):
iran = (ART / "2024-10-25-Taq-e-Bostan_000521270020.md").read_text()
kazakhstan = (ART / "2024-10-09-x_DSC-0999.md").read_text()
self.assertIn('anchor: "Iran - Hamadan, Kermanshah, Tabriz"', iran)
self.assertIn('anchor: "Kazakhstan - World Nomad Games"', kazakhstan)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
import os
import runpy
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
class PhotoManifestTest(unittest.TestCase):
def test_photo_receipts_accept_single_and_double_quoted_ids(self):
first = "a" * 64
second = "b" * 64
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "_site/art").mkdir(parents=True)
(root / "_site/snippets").mkdir(parents=True)
(root / "_site/art/index.html").write_text(
f'<span id="photo-{first}"></span><span id=\'photo-{second}\'></span>'
)
(root / "_site/snippets/index.html").write_text("")
with patch("os.environ", {"GITHUB_SHA": "test-commit"}):
old_cwd = Path.cwd()
try:
os.chdir(root)
script = Path(__file__).resolve().parents[1] / "photo-manifest.py"
runpy.run_path(str(script))
finally:
os.chdir(old_cwd)
manifest = (root / "_site/photo-publication.json").read_text()
self.assertIn(first, manifest)
self.assertIn(second, manifest)
if __name__ == "__main__":
unittest.main()