Support optional highlight names for uploaded photo batches
This commit is contained in:
@@ -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
@@ -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
@@ -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>')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user