Support optional highlight names for uploaded photo batches
This commit is contained in:
+10
-4
@@ -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/<id>`: `queued`, `publishing`, `pushed`, `published`, or `failed`.
|
||||
- `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
|
||||
submission; edit an existing entry through Git. Keep the upload response/status URL.
|
||||
|
||||
|
||||
+15
-3
@@ -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'<div id="photo-{job}"></div>', ""]
|
||||
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>')
|
||||
|
||||
@@ -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" & <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):
|
||||
return subprocess.check_output(["git", "-C", str(directory), *args], text=True).strip()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user