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
+7 -4
View File
@@ -150,10 +150,13 @@ Create **Publish to blog**:
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
upload. Each photo renders as its own gallery `<li>`, including photos in older
batches. The caption appears on each photo; the first image is the highlight target. 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.
upload job. Each photo is stored as its own `_art` document and renders as its
own gallery `<li>`. The shared caption is repeated on each photo and an optional
highlight begins on the first photo. Photos remain contiguous in the selection order.
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
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**,
+30 -16
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import re
import shutil
import tempfile
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import warnings
import zipfile
@@ -79,6 +79,26 @@ def web_image(raw, destination):
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):
app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=100 * 1024 * 1024,
@@ -261,21 +281,15 @@ def create_app(data_dir=None, token=None):
if kind == "snippet":
manifest.update(kind=kind, text=text)
atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"',
f'date: "{date}"', "categories: art"]
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)):
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>")
if kind == "snippet":
if kind == "photo":
(temporary / "entries").mkdir()
for index in range(len(photos)):
(temporary / "entries" / f"{index:02}.md").write_text(
photo_entry(job, date, index, len(photos), caption, highlight))
# Retain the old filename for compatibility with publisher versions
# that predate one-document-per-photo submissions.
content = [(temporary / "entries/00.md").read_text()]
else:
content = [f'<div id="snippet-{job}"></div>', ""]
for paragraph in (text, caption):
if paragraph:
+13 -2
View File
@@ -86,9 +86,20 @@ class Publisher:
entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md"
images = Path("img/arts/uploads") / job
(self.repo / entry).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(submission / "entry.md", self.repo / entry)
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)
self.git("add", "--", str(entry))
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"):
self.git("commit", "-m", f"Publish {len(jobs)} submission(s)")
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
job = response.json["id"]
folder = data / "submissions" / job
entry = (folder / "entry.md").read_text()
assert "<script>" not in entry and "{%" not in entry and "{{" not in entry
assert "<br>second line" in entry
assert entry.index("00.jpg") < entry.index("01.jpg")
entries = sorted((folder / "entries").glob("*.md"))
assert len(entries) == 2
rendered = "\n".join(entry.read_text() for entry in entries)
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/01.jpg").getpixel((0, 0))[2] > 240
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
job = response.json["id"]
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:]
anchor = next(line for line in metadata.splitlines() if line.startswith("anchor: "))
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.
assert f'title: "photo-{job}"' in metadata
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 upload(client, images, "Album caption", highlight=name).status_code == 200
assert upload(client, images, "Album caption", highlight="Other trip").json["id"] != job