From f4037695a131f208c8beac7235239b24ac5835ac Mon Sep 17 00:00:00 2001 From: Mahdi Dibaiee Date: Sat, 19 Sep 2026 14:56:25 +0330 Subject: [PATCH] Support optional highlight names for uploaded photo batches --- art.html | 2 +- photo-upload/README.md | 14 +++++++---- photo-upload/app.py | 18 +++++++++++--- photo-upload/tests/test_upload.py | 40 ++++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 9 deletions(-) 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()