diff --git a/art.html b/art.html
index 6dc580d..0c25fd0 100644
--- a/art.html
+++ b/art.html
@@ -19,7 +19,7 @@ layout: default
{{ post.date | date: "%Y" }}
- {{ post.anchor }}
+ {{ post.anchor | escape }}
{% endfor %}
diff --git a/photo-upload/README.md b/photo-upload/README.md
index 5c1fa50..a9d8358 100644
--- a/photo-upload/README.md
+++ b/photo-upload/README.md
@@ -1,6 +1,6 @@
# iPhone → blog, with GitHub Actions
-Photos → Share → **Publish to blog** → optional caption → upload.
+Photos → Share → **Publish to blog** → optional caption and highlight name → upload.
The authenticated upload service converts photos to JPEG (maximum 2560 px), applies
orientation, converts embedded color profiles to sRGB, and strips public metadata.
@@ -130,14 +130,17 @@ Create **Publish to blog**:
1. Enable **Show in Share Sheet**, accepting **Images**.
2. **Ask for Input** (Text): “Caption (optional)”.
-3. **Get Contents of URL**: `https://theread.me/api/photos`.
+3. Add another **Ask for Input** (Text): “Highlight name (optional)”.
+4. **Get Contents of URL**: `https://theread.me/api/photos`.
- Method: **POST**.
- Header: `Authorization` = `Bearer YOUR_TOKEN`.
- Request Body: **Form**.
- `photos`: type **File**, value **Shortcut Input** (the selected images).
- `caption`: type **Text**, value the caption response.
+ - `highlight`: type **Text**, value the highlight-name response. Select each
+ prompt's specific output variable so the caption and highlight stay separate.
- Do not set Content-Type; Shortcuts supplies the multipart boundary.
-4. Show “Uploaded; publishing” on success. Optionally poll the returned `status_url`
+5. Show “Uploaded; publishing” on success. Optionally poll the returned `status_url`
with the same header. `pushed` means “GitHub Actions is building/deploying”;
only `published` means the deployed site's receipt contains this album.
@@ -145,6 +148,8 @@ Multi-photo albums require repeated `photos` parts in one request. Verify that
file-list behavior on the target iPhone, starting with one photo and then two.
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**,
+pointing to the first photo in the batch. Leave it blank for an ordinary upload.
The token is visible in the shortcut editor; remove it before sharing the shortcut.
Rotate it by replacing the token file and recreating the upload container with
@@ -155,11 +160,12 @@ Rotate it by replacing the token file and recreating the upload container with
All API routes require the bearer token:
- `POST /api/photos`: 1–20 multipart `photos`, optional `caption` (4000 characters).
+ Optional `highlight` (200 characters) names a Highlights link to the batch's first photo.
Maximum request 100 MiB; each photo at most 60 megapixels.
- `GET /api/photos/`: `queued`, `publishing`, `pushed`, `published`, or `failed`.
- `POST /api/photos//retry`: retry a failed Git publication.
-Identical original bytes, ordering, and caption produce the same ID. Retrying does
+Identical original bytes, ordering, caption, and highlight name produce the same ID. Retrying does
not duplicate entries or commits. Re-encoding or changing the caption creates a new
submission; edit an existing entry through Git. Keep the upload response/status URL.
diff --git a/photo-upload/app.py b/photo-upload/app.py
index b137fcc..5cb4e2a 100644
--- a/photo-upload/app.py
+++ b/photo-upload/app.py
@@ -128,6 +128,9 @@ def create_app(data_dir=None, token=None):
caption = request.form.get("caption", "").strip()
if len(caption) > 4000:
abort(400, "Caption must be at most 4000 characters")
+ highlight = request.form.get("highlight", "").strip()
+ if len(highlight) > 200:
+ abort(400, "Highlight name must be at most 200 characters")
# Ordered byte hashes + caption make retries idempotent, even across restarts.
digest = hashlib.sha256(caption.encode())
temporary = Path(tempfile.mkdtemp(dir=data / "temporary"))
@@ -144,13 +147,22 @@ def create_app(data_dir=None, token=None):
Image.DecompressionBombError, Image.DecompressionBombWarning,
ImageCms.PyCMSError):
abort(400, f"Photo {index + 1} is unsupported, damaged, or exceeds 60 megapixels")
+ # Preserve existing IDs when omitted; named highlights participate in deduplication.
+ if highlight:
+ digest.update(b"\0highlight\0" + highlight.encode())
job = digest.hexdigest()
date = datetime.now(timezone.utc).isoformat()
- manifest = {"id": job, "date": date, "caption": caption, "count": len(photos)}
+ manifest = {"id": job, "date": date, "caption": caption,
+ "highlight": highlight, "count": len(photos)}
atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"',
- f'date: "{date}"', "categories: art", "---", "",
- f'', ""]
+ 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'', ""])
for index in range(len(photos)):
content.append(f'
')
diff --git a/photo-upload/tests/test_upload.py b/photo-upload/tests/test_upload.py
index b652510..70597e7 100644
--- a/photo-upload/tests/test_upload.py
+++ b/photo-upload/tests/test_upload.py
@@ -1,4 +1,5 @@
import io
+import hashlib
import json
from pathlib import Path
import subprocess
@@ -28,9 +29,10 @@ def photo(color="red", format="JPEG", size=(40, 20), exif=None):
return output.getvalue()
-def upload(client, images=None, caption="", headers=AUTH):
+def upload(client, images=None, caption="", headers=AUTH, highlight=None):
return client.post("/api/photos", headers=headers, data={
"caption": caption,
+ **({"highlight": highlight} if highlight is not None else {}),
"photos": [(io.BytesIO(raw), "../../escape.jpg") for raw in (images or [photo()])],
})
@@ -96,6 +98,42 @@ def test_orientation_metadata_and_heic(service):
assert image.height > image.width
+def test_optional_highlight_preserves_existing_upload_ids(service):
+ _, client, data = service
+ raw = photo()
+ digest = hashlib.sha256(b"Caption")
+ digest.update(hashlib.sha256(raw).digest())
+ response = upload(client, [raw], "Caption")
+ assert response.json["id"] == digest.hexdigest()
+ assert upload(client, [raw], "Caption", highlight=" ").json["id"] == digest.hexdigest()
+ entry = (data / "submissions" / digest.hexdigest() / "entry.md").read_text()
+ assert "anchor:" not in entry
+ assert entry.count(f'id="photo-{digest.hexdigest()}"') == 1
+
+
+def test_highlight_album_and_safe_front_matter(service):
+ _, client, data = service
+ name = 'سفر: "Summer" & \n---\npublished: false'
+ images = [photo("red"), photo("blue")]
+ response = upload(client, images, "Album caption", highlight=name)
+ assert response.status_code == 202
+ job = response.json["id"]
+ folder = data / "submissions" / job
+ entry = (folder / "entry.md").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
+ assert "\npublished:" not in metadata
+ # 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 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
+ assert upload(client, images, highlight="a" * 201).status_code == 400
+
+
def git(directory, *args):
return subprocess.check_output(["git", "-C", str(directory), *args], text=True).strip()