Merge photo highlights by name and capture year
This commit is contained in:
@@ -179,8 +179,11 @@ photos. When none of these date sources is available, the service uses the uploa
|
||||
date. Re-sharing the same images, caption, and
|
||||
highlight with dates replaces the earlier undated upload. Captions are plain
|
||||
text and optional.
|
||||
An optional highlight name (up to 200 characters) adds one link to **Highlights**,
|
||||
pointing to the first photo in the batch. Leave it blank for an ordinary upload.
|
||||
An optional highlight name (up to 200 characters) adds a link to **Highlights**.
|
||||
If that name already exists in the same capture year, publishing merges the new
|
||||
photos into that highlight and keeps one link at its newest photo. A batch spanning
|
||||
years gets one link per year. Photo dates and gallery order stay unchanged. Leave
|
||||
the name blank for an ordinary upload.
|
||||
|
||||
Gallery images are progressive JPEGs with a maximum edge of 1600 pixels and a
|
||||
300 KiB size limit. The uploader tries moderate JPEG quality levels, then reduces
|
||||
|
||||
@@ -177,6 +177,8 @@ def photo_entry(job, date, index, caption, highlight):
|
||||
f'date: "{item_date.isoformat()}"', "categories: art"]
|
||||
if index == 0 and highlight:
|
||||
content.append(f"anchor: {json.dumps(highlight)}")
|
||||
if highlight:
|
||||
content.append(f"highlight_name: {json.dumps(highlight)}")
|
||||
content.extend(["---", ""])
|
||||
if index == 0 and not highlight:
|
||||
content.extend([f'<div id="photo-{job}"></div>', ""])
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Keep one gallery highlight link per name and capture year."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def field(content, name):
|
||||
match = re.search(rf"^{re.escape(name)}: (.+)$", content.split("---", 2)[1], re.MULTILINE)
|
||||
if not match:
|
||||
return None
|
||||
value = match.group(1).strip()
|
||||
return json.loads(value) if value.startswith('"') else value
|
||||
|
||||
|
||||
def normalize_highlights(art):
|
||||
"""Place each link on the newest photo in its named, dated group."""
|
||||
groups = {}
|
||||
for path in Path(art).glob("*.md"):
|
||||
content = path.read_text()
|
||||
name = field(content, "highlight_name") or field(content, "anchor")
|
||||
date = field(content, "date")
|
||||
if not name or not date:
|
||||
continue
|
||||
taken = datetime.fromisoformat(str(date))
|
||||
if taken.tzinfo is None:
|
||||
taken = taken.replace(tzinfo=timezone.utc)
|
||||
groups.setdefault((name, taken.year), []).append((taken, path, content))
|
||||
|
||||
changed = []
|
||||
for (name, _), entries in groups.items():
|
||||
# An old single anchor is already the only link; a newer named photo
|
||||
# moves the link without changing any photo's capture date or position.
|
||||
newest = max(entries, key=lambda entry: (entry[0], entry[1].name))[1]
|
||||
if len(entries) == 1 and field(entries[0][2], "anchor") == name:
|
||||
continue
|
||||
for _, path, content in entries:
|
||||
old = content
|
||||
had_anchor = field(content, "anchor") is not None
|
||||
if (path == newest and had_anchor and
|
||||
all(field(other[2], "anchor") is None for other in entries if other[1] != path)):
|
||||
continue
|
||||
if had_anchor:
|
||||
content = re.sub(r"^anchor: .+\n", "", content, count=1, flags=re.MULTILINE)
|
||||
if path == newest:
|
||||
content = content.replace("categories: art\n", "categories: art\n" +
|
||||
f"anchor: {json.dumps(name, ensure_ascii=False)}\n", 1)
|
||||
elif had_anchor:
|
||||
title = field(content, "title")
|
||||
if title and title.startswith("photo-") and f'id="{title}"' not in content:
|
||||
content = content.replace("---\n\n", f'---\n\n<div id="{title}"></div>\n\n', 1)
|
||||
if content != old:
|
||||
path.write_text(content)
|
||||
changed.append(path)
|
||||
return changed
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import time
|
||||
|
||||
from app import atomic_json
|
||||
from highlights import normalize_highlights
|
||||
from posts import publish_post
|
||||
|
||||
log = logging.getLogger("publisher")
|
||||
@@ -117,6 +118,8 @@ class Publisher:
|
||||
self.git("add", "--", str(entry))
|
||||
shutil.copytree(submission / "images", self.repo / images, dirs_exist_ok=True)
|
||||
self.git("add", "--", str(images))
|
||||
for path in normalize_highlights(self.repo / "_art"):
|
||||
self.git("add", "--", str(path.relative_to(self.repo)))
|
||||
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}")
|
||||
|
||||
@@ -379,6 +379,56 @@ def test_publisher_names_photo_entry_from_capture_date(publisher, service):
|
||||
assert f'_art/2025-04-18-photo-{response.json["id"]}-00.md' in paths
|
||||
|
||||
|
||||
def test_publisher_merges_highlights_by_name_and_capture_year(publisher):
|
||||
run, remote, client = publisher
|
||||
first = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [(io.BytesIO(photo("red")), "first.jpg")],
|
||||
"highlight": "Trip", "photo_dates": "2026-03-01T12:00:00+00:00",
|
||||
}).json["id"]
|
||||
assert run().returncode == 0
|
||||
second = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [(io.BytesIO(photo("blue")), "second.jpg")],
|
||||
"highlight": "Trip", "photo_dates": "2026-06-01T12:00:00+00:00",
|
||||
}).json["id"]
|
||||
assert run().returncode == 0
|
||||
files = git(remote, "ls-tree", "-r", "--name-only", "master").splitlines()
|
||||
entries = [git(remote, "show", f"master:{path}") for path in files if path.startswith("_art/")]
|
||||
assert sum('anchor: "Trip"' in entry for entry in entries) == 1
|
||||
assert f'anchor: "Trip"' in next(entry for entry in entries if f"photo-{second}" in entry)
|
||||
assert f'<div id="photo-{first}"></div>' in next(
|
||||
entry for entry in entries if f'title: "photo-{first}"' in entry)
|
||||
old_year = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [(io.BytesIO(photo("green")), "old-year.jpg")],
|
||||
"highlight": "Trip", "photo_dates": "2025-12-31T12:00:00+00:00",
|
||||
}).json["id"]
|
||||
assert run().returncode == 0
|
||||
files = git(remote, "ls-tree", "-r", "--name-only", "master").splitlines()
|
||||
entries = [git(remote, "show", f"master:{path}") for path in files if path.startswith("_art/")]
|
||||
assert sum('anchor: "Trip"' in entry for entry in entries) == 2
|
||||
assert 'anchor: "Trip"' in next(entry for entry in entries if f"photo-{old_year}" in entry)
|
||||
commit = git(remote, "rev-parse", "master")
|
||||
assert run().returncode == 0
|
||||
assert git(remote, "rev-parse", "master") == commit
|
||||
|
||||
|
||||
def test_publisher_splits_a_highlight_across_capture_years(publisher):
|
||||
run, remote, client = publisher
|
||||
response = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [(io.BytesIO(photo("red")), "old.jpg"),
|
||||
(io.BytesIO(photo("blue")), "new.jpg")],
|
||||
"highlight": "Trip",
|
||||
"photo_dates": "2025-12-31T12:00:00+00:00\n2026-01-01T12:00:00+00:00",
|
||||
})
|
||||
assert response.status_code == 202
|
||||
result = run()
|
||||
assert result.returncode == 0, result.stderr
|
||||
job = response.json["id"]
|
||||
files = git(remote, "ls-tree", "-r", "--name-only", "master").splitlines()
|
||||
entries = [git(remote, "show", f"master:{path}") for path in files if f"photo-{job}" in path]
|
||||
assert len(entries) == 2
|
||||
assert all('anchor: "Trip"' in entry for entry in entries)
|
||||
|
||||
|
||||
def test_publisher_replaces_an_undated_version_of_the_same_photos(publisher, service):
|
||||
run, remote, client = publisher
|
||||
raw = photo()
|
||||
|
||||
Reference in New Issue
Block a user