Support analog photo dates and correct gallery years
This commit is contained in:
+10
-4
@@ -136,11 +136,14 @@ Create **Publish to blog**:
|
||||
**ISO 8601**. After **End Repeat**, **Combine Text** from **Repeat Results**
|
||||
with **New Lines** and save it as **Photo Dates**. This preserves capture
|
||||
dates even when Shortcuts strips them from the exported JPEG data.
|
||||
5. **Make Archive** from **Shortcut Input**, format **ZIP**. Keep this output in
|
||||
5. Add **Ask for Input**, type **Text**, with the prompt **Date Taken (optional;
|
||||
YYYY, YYYY-MM-DD, or ISO timestamp)**. Save its response as **Date Taken**.
|
||||
This is useful for analog scans; leave it blank for digital photos.
|
||||
6. **Make Archive** from **Shortcut Input**, format **ZIP**. Keep this output in
|
||||
a variable named **Photo Archive**. This packages every selected image into
|
||||
one upload; using an image list directly in a File form field can send only
|
||||
the first image on some Shortcuts versions.
|
||||
6. **Get Contents of URL**: `https://theread.me/api/photos`.
|
||||
7. **Get Contents of URL**: `https://theread.me/api/photos`.
|
||||
- Method: **POST**.
|
||||
- Header: `Authorization` = `Bearer YOUR_TOKEN`.
|
||||
- Request Body: **Form**.
|
||||
@@ -149,8 +152,9 @@ Create **Publish to blog**:
|
||||
- `highlight`: type **Text**, value the highlight-name response. Select each
|
||||
prompt's specific output variable so the caption and highlight stay separate.
|
||||
- `photo_dates`: type **Text**, value **Photo Dates**.
|
||||
- `date_taken`: type **Text**, value **Date Taken**.
|
||||
- Do not set Content-Type; Shortcuts supplies the multipart boundary.
|
||||
7. Show the actual response and check its `count` against the number of selected
|
||||
8. Show the actual response and check its `count` against the number of selected
|
||||
images. Only show “Uploaded; publishing” when there is no `error`. 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.
|
||||
@@ -167,7 +171,9 @@ Select still photos; videos/Live Photo video components aren't supported. The
|
||||
service reads EXIF `DateTimeOriginal` automatically when it is present. Because
|
||||
iOS Shortcuts can blank that EXIF field while creating the ZIP, `photo_dates`
|
||||
supplies the same Photos-library metadata explicitly, one ISO 8601 value per
|
||||
image in archive order. An upload without either source is rejected instead of
|
||||
image in archive order. `date_taken` overrides both sources for the whole batch;
|
||||
it accepts a year, calendar date, or ISO timestamp and is intended for analog
|
||||
photos. An upload without any date source is rejected instead of
|
||||
being placed under the upload year. Re-sharing the same images, caption, and
|
||||
highlight with dates replaces the earlier undated upload. Captions are plain
|
||||
text and optional.
|
||||
|
||||
+29
-7
@@ -6,6 +6,7 @@ import html
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
@@ -79,14 +80,30 @@ def submitted_photo_dates(raw, count):
|
||||
return dates
|
||||
|
||||
|
||||
def submitted_batch_date(raw):
|
||||
"""Parse one optional date override for an entire analog-photo batch."""
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
if re.fullmatch(r"\d{4}", value):
|
||||
return datetime(int(value), 1, 1)
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("date_taken must be a year, ISO date, or ISO timestamp") from error
|
||||
|
||||
|
||||
def distinct_photo_dates(dates):
|
||||
"""Keep exact capture times, using earlier seconds only to break true ties."""
|
||||
"""Keep exact capture times, using later seconds only to break true ties."""
|
||||
result = []
|
||||
used = set()
|
||||
remaining = Counter(taken.isoformat() for taken in dates)
|
||||
for taken in dates:
|
||||
candidate = taken
|
||||
key = taken.isoformat()
|
||||
candidate = taken + timedelta(seconds=remaining[key] - 1)
|
||||
remaining[key] -= 1
|
||||
while candidate.isoformat() in used:
|
||||
candidate -= timedelta(seconds=1)
|
||||
candidate += timedelta(seconds=1)
|
||||
result.append(candidate)
|
||||
used.add(candidate.isoformat())
|
||||
return result
|
||||
@@ -321,6 +338,8 @@ def create_app(data_dir=None, token=None):
|
||||
try:
|
||||
supplied_dates = (submitted_photo_dates(request.form.get("photo_dates", ""), len(photos))
|
||||
if kind == "photo" else None)
|
||||
batch_date = (submitted_batch_date(request.form.get("date_taken", ""))
|
||||
if kind == "photo" else None)
|
||||
except ValueError as error:
|
||||
abort(400, str(error))
|
||||
# Ordered byte hashes + caption make retries idempotent, even across restarts.
|
||||
@@ -339,7 +358,8 @@ def create_app(data_dir=None, token=None):
|
||||
try:
|
||||
embedded_date = web_image(raw, temporary / "images" / f"{index:02}.jpg")
|
||||
if kind == "photo":
|
||||
capture_dates.append(supplied_dates[index] if supplied_dates else embedded_date)
|
||||
capture_dates.append(batch_date or
|
||||
(supplied_dates[index] if supplied_dates else embedded_date))
|
||||
except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
|
||||
Image.DecompressionBombError, Image.DecompressionBombWarning,
|
||||
ImageCms.PyCMSError):
|
||||
@@ -348,14 +368,16 @@ def create_app(data_dir=None, token=None):
|
||||
if highlight:
|
||||
digest.update(b"\0highlight\0" + highlight.encode())
|
||||
undated_job = digest.hexdigest()
|
||||
if supplied_dates:
|
||||
if batch_date:
|
||||
digest.update(b"\0date_taken\0" + batch_date.isoformat().encode())
|
||||
elif supplied_dates:
|
||||
normalized_dates = "\n".join(taken.isoformat() for taken in supplied_dates)
|
||||
digest.update(b"\0photo_dates\0" + normalized_dates.encode())
|
||||
job = digest.hexdigest()
|
||||
date = datetime.now(timezone.utc).isoformat()
|
||||
manifest = {"id": job, "date": date, "caption": caption,
|
||||
"highlight": highlight, "count": len(photos)}
|
||||
if (supplied_dates and undated_job != job and
|
||||
if ((batch_date or supplied_dates) and undated_job != job and
|
||||
(data / "submissions" / undated_job / "manifest.json").is_file()):
|
||||
manifest["replaces"] = undated_job
|
||||
if kind == "snippet":
|
||||
@@ -363,7 +385,7 @@ def create_app(data_dir=None, token=None):
|
||||
atomic_json(temporary / "manifest.json", manifest)
|
||||
if kind == "photo":
|
||||
if any(taken is None for taken in capture_dates):
|
||||
abort(400, "Photo capture date is missing; send photo_dates from the Photos library")
|
||||
abort(400, "Photo capture date is missing; send date_taken or photo_dates")
|
||||
capture_dates = distinct_photo_dates(capture_dates)
|
||||
manifest["capture_dates"] = [taken.isoformat() for taken in capture_dates]
|
||||
atomic_json(temporary / "manifest.json", manifest)
|
||||
|
||||
@@ -195,6 +195,28 @@ def test_photo_dates_can_come_from_photos_library_metadata(service):
|
||||
assert "capture date is missing" in missing.json["error"]
|
||||
|
||||
|
||||
def test_batch_date_taken_overrides_metadata_for_analog_photos(service):
|
||||
_, client, data = service
|
||||
response = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [
|
||||
(io.BytesIO(photo(exif=Image.Exif())), "first.jpg"),
|
||||
(io.BytesIO(photo(exif=Image.Exif())), "second.jpg"),
|
||||
],
|
||||
"photo_dates": "2026-01-02T03:04:05+00:00\n2026-01-03T03:04:05+00:00",
|
||||
"date_taken": "2025",
|
||||
})
|
||||
assert response.status_code == 202
|
||||
manifest = json.loads((data / "submissions" / response.json["id"] / "manifest.json").read_text())
|
||||
assert all(value.startswith("2025-01-01T") for value in manifest["capture_dates"])
|
||||
|
||||
invalid = client.post("/api/photos", headers=AUTH, data={
|
||||
"photos": [(io.BytesIO(photo()), "photo.jpg")],
|
||||
"date_taken": "last summer",
|
||||
})
|
||||
assert invalid.status_code == 400
|
||||
assert "year, ISO date, or ISO timestamp" in invalid.json["error"]
|
||||
|
||||
|
||||
def test_dated_retry_replaces_the_same_undated_upload(service):
|
||||
_, client, data = service
|
||||
raw = photo()
|
||||
|
||||
Reference in New Issue
Block a user