Support optional highlight names for uploaded photo batches

This commit is contained in:
2026-09-19 14:56:25 +03:30
parent 86dcfd71cb
commit f4037695a1
4 changed files with 65 additions and 9 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ layout: default
<span class='meta'> <span class='meta'>
{{ post.date | date: "%Y" }} {{ post.date | date: "%Y" }}
</span> </span>
{{ post.anchor }} {{ post.anchor | escape }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
+10 -4
View File
@@ -1,6 +1,6 @@
# iPhone → blog, with GitHub Actions # 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 The authenticated upload service converts photos to JPEG (maximum 2560 px), applies
orientation, converts embedded color profiles to sRGB, and strips public metadata. 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**. 1. Enable **Show in Share Sheet**, accepting **Images**.
2. **Ask for Input** (Text): “Caption (optional)”. 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**. - Method: **POST**.
- Header: `Authorization` = `Bearer YOUR_TOKEN`. - Header: `Authorization` = `Bearer YOUR_TOKEN`.
- Request Body: **Form**. - Request Body: **Form**.
- `photos`: type **File**, value **Shortcut Input** (the selected images). - `photos`: type **File**, value **Shortcut Input** (the selected images).
- `caption`: type **Text**, value the caption response. - `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. - 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”; with the same header. `pushed` means “GitHub Actions is building/deploying”;
only `published` means the deployed site's receipt contains this album. 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. 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 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**,
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. 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 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: All API routes require the bearer token:
- `POST /api/photos`: 1–20 multipart `photos`, optional `caption` (4000 characters). - `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. Maximum request 100 MiB; each photo at most 60 megapixels.
- `GET /api/photos/<id>`: `queued`, `publishing`, `pushed`, `published`, or `failed`. - `GET /api/photos/<id>`: `queued`, `publishing`, `pushed`, `published`, or `failed`.
- `POST /api/photos/<id>/retry`: retry a failed Git publication. - `POST /api/photos/<id>/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 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. submission; edit an existing entry through Git. Keep the upload response/status URL.
+15 -3
View File
@@ -128,6 +128,9 @@ def create_app(data_dir=None, token=None):
caption = request.form.get("caption", "").strip() caption = request.form.get("caption", "").strip()
if len(caption) > 4000: if len(caption) > 4000:
abort(400, "Caption must be at most 4000 characters") 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. # Ordered byte hashes + caption make retries idempotent, even across restarts.
digest = hashlib.sha256(caption.encode()) digest = hashlib.sha256(caption.encode())
temporary = Path(tempfile.mkdtemp(dir=data / "temporary")) temporary = Path(tempfile.mkdtemp(dir=data / "temporary"))
@@ -144,13 +147,22 @@ def create_app(data_dir=None, token=None):
Image.DecompressionBombError, Image.DecompressionBombWarning, Image.DecompressionBombError, Image.DecompressionBombWarning,
ImageCms.PyCMSError): ImageCms.PyCMSError):
abort(400, f"Photo {index + 1} is unsupported, damaged, or exceeds 60 megapixels") 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() job = digest.hexdigest()
date = datetime.now(timezone.utc).isoformat() 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) atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"', content = ["---", "layout: post", f'title: "photo-{job}"',
f'date: "{date}"', "categories: art", "---", "", f'date: "{date}"', "categories: art"]
f'<div id="photo-{job}"></div>', ""] 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" ' content.append(f'<p><img src="/img/arts/uploads/{job}/{index:02}.jpg" '
f'alt="{public_text(caption)}" loading="lazy"></p>') f'alt="{public_text(caption)}" loading="lazy"></p>')
+39 -1
View File
@@ -1,4 +1,5 @@
import io import io
import hashlib
import json import json
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -28,9 +29,10 @@ def photo(color="red", format="JPEG", size=(40, 20), exif=None):
return output.getvalue() 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={ return client.post("/api/photos", headers=headers, data={
"caption": caption, "caption": caption,
**({"highlight": highlight} if highlight is not None else {}),
"photos": [(io.BytesIO(raw), "../../escape.jpg") for raw in (images or [photo()])], "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 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" & <friends>\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): def git(directory, *args):
return subprocess.check_output(["git", "-C", str(directory), *args], text=True).strip() return subprocess.check_output(["git", "-C", str(directory), *args], text=True).strip()