Date photo uploads from capture metadata
This commit is contained in:
+17
-5
@@ -131,11 +131,16 @@ 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. Add another **Ask for Input** (Text): “Highlight name (optional)”.
|
3. Add another **Ask for Input** (Text): “Highlight name (optional)”.
|
||||||
4. **Make Archive** from **Shortcut Input**, format **ZIP**. Keep this output in
|
4. **Repeat with Each** item in **Shortcut Input**. Inside the repeat, use
|
||||||
|
**Get Details of Images** to get **Date Taken**, then **Format Date** using
|
||||||
|
**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
|
||||||
a variable named **Photo Archive**. This packages every selected image into
|
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
|
one upload; using an image list directly in a File form field can send only
|
||||||
the first image on some Shortcuts versions.
|
the first image on some Shortcuts versions.
|
||||||
5. **Get Contents of URL**: `https://theread.me/api/photos`.
|
6. **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**.
|
||||||
@@ -143,8 +148,9 @@ Create **Publish to blog**:
|
|||||||
- `caption`: type **Text**, value the caption response.
|
- `caption`: type **Text**, value the caption response.
|
||||||
- `highlight`: type **Text**, value the highlight-name response. Select each
|
- `highlight`: type **Text**, value the highlight-name response. Select each
|
||||||
prompt's specific output variable so the caption and highlight stay separate.
|
prompt's specific output variable so the caption and highlight stay separate.
|
||||||
|
- `photo_dates`: type **Text**, value **Photo Dates**.
|
||||||
- Do not set Content-Type; Shortcuts supplies the multipart boundary.
|
- Do not set Content-Type; Shortcuts supplies the multipart boundary.
|
||||||
6. Show the actual response and check its `count` against the number of selected
|
7. 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`
|
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”;
|
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.
|
||||||
@@ -157,8 +163,14 @@ Older batch documents are still split at render time for backward compatibility.
|
|||||||
Metadata sidecars under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API
|
Metadata sidecars under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API
|
||||||
clients can also send repeated `photos` parts; send either `archive` or `photos`,
|
clients can also send repeated `photos` parts; send either `archive` or `photos`,
|
||||||
never both.
|
never both.
|
||||||
Select still photos; videos/Live Photo video components aren't supported. Dates
|
Select still photos; videos/Live Photo video components aren't supported. The
|
||||||
are upload time in UTC; captions are plain text. A caption is optional.
|
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
|
||||||
|
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.
|
||||||
An optional highlight name (up to 200 characters) adds one link to **Highlights**,
|
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.
|
pointing to the first photo in the batch. Leave it blank for an ordinary upload.
|
||||||
|
|
||||||
|
|||||||
+93
-10
@@ -15,7 +15,7 @@ import warnings
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from flask import Flask, abort, jsonify, request
|
from flask import Flask, abort, jsonify, request
|
||||||
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
|
from PIL import ExifTags, Image, ImageCms, ImageOps, UnidentifiedImageError
|
||||||
from pillow_heif import register_heif_opener
|
from pillow_heif import register_heif_opener
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
from posts import parse_upload
|
from posts import parse_upload
|
||||||
@@ -27,6 +27,71 @@ GALLERY_MAX_BYTES = 300 * 1024
|
|||||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||||
|
|
||||||
|
|
||||||
|
def exif_photo_date(exif):
|
||||||
|
"""Return the original capture time without exposing or retaining other metadata."""
|
||||||
|
try:
|
||||||
|
nested = exif.get_ifd(ExifTags.IFD.Exif)
|
||||||
|
except (AttributeError, KeyError, TypeError, ValueError):
|
||||||
|
nested = {}
|
||||||
|
|
||||||
|
def value(tag):
|
||||||
|
candidate = nested.get(tag, exif.get(tag))
|
||||||
|
if isinstance(candidate, bytes):
|
||||||
|
candidate = candidate.decode("ascii", "ignore")
|
||||||
|
return str(candidate or "").strip(" \0")
|
||||||
|
|
||||||
|
# Prefer when the shutter fired, then digitization time, then the generic
|
||||||
|
# image timestamp. Offset and subseconds belong to their matching date tag.
|
||||||
|
for date_tag, offset_tag, subsecond_tag in (
|
||||||
|
(36867, 36881, 37521), (36868, 36882, 37522), (306, 36880, 37520)):
|
||||||
|
raw_date = value(date_tag)
|
||||||
|
if not raw_date:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
taken = datetime.strptime(raw_date, "%Y:%m:%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
subseconds = re.sub(r"\D", "", value(subsecond_tag))[:6]
|
||||||
|
if subseconds:
|
||||||
|
taken = taken.replace(microsecond=int(subseconds.ljust(6, "0")))
|
||||||
|
offset = value(offset_tag)
|
||||||
|
if re.fullmatch(r"[+-]\d{2}:\d{2}", offset):
|
||||||
|
sign = 1 if offset[0] == "+" else -1
|
||||||
|
hours, minutes = map(int, offset[1:].split(":"))
|
||||||
|
taken = taken.replace(tzinfo=timezone(sign * timedelta(hours=hours, minutes=minutes)))
|
||||||
|
return taken
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def submitted_photo_dates(raw, count):
|
||||||
|
"""Parse newline-separated ISO dates supplied from the Photos library."""
|
||||||
|
if not raw.strip():
|
||||||
|
return None
|
||||||
|
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||||
|
if len(lines) != count:
|
||||||
|
raise ValueError("photo_dates must contain one ISO date per photo")
|
||||||
|
dates = []
|
||||||
|
for line in lines:
|
||||||
|
try:
|
||||||
|
dates.append(datetime.fromisoformat(line.replace("Z", "+00:00")))
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("photo_dates must contain valid ISO dates") from error
|
||||||
|
return dates
|
||||||
|
|
||||||
|
|
||||||
|
def distinct_photo_dates(dates):
|
||||||
|
"""Keep exact capture times, using earlier seconds only to break true ties."""
|
||||||
|
result = []
|
||||||
|
used = set()
|
||||||
|
for taken in dates:
|
||||||
|
candidate = taken
|
||||||
|
while candidate.isoformat() in used:
|
||||||
|
candidate -= timedelta(seconds=1)
|
||||||
|
result.append(candidate)
|
||||||
|
used.add(candidate.isoformat())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def atomic_json(path, value):
|
def atomic_json(path, value):
|
||||||
fd, temporary = tempfile.mkstemp(dir=path.parent)
|
fd, temporary = tempfile.mkstemp(dir=path.parent)
|
||||||
try:
|
try:
|
||||||
@@ -48,6 +113,7 @@ def web_image(raw, destination):
|
|||||||
with Image.open(io.BytesIO(raw)) as original:
|
with Image.open(io.BytesIO(raw)) as original:
|
||||||
if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}:
|
if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}:
|
||||||
raise ValueError("Use JPEG, PNG, WebP, or HEIC photos")
|
raise ValueError("Use JPEG, PNG, WebP, or HEIC photos")
|
||||||
|
taken_at = exif_photo_date(original.getexif())
|
||||||
image = ImageOps.exif_transpose(original)
|
image = ImageOps.exif_transpose(original)
|
||||||
image.thumbnail((GALLERY_MAX_EDGE, GALLERY_MAX_EDGE), Image.Resampling.LANCZOS)
|
image.thumbnail((GALLERY_MAX_EDGE, GALLERY_MAX_EDGE), Image.Resampling.LANCZOS)
|
||||||
if image.info.get("icc_profile"):
|
if image.info.get("icc_profile"):
|
||||||
@@ -74,17 +140,14 @@ def web_image(raw, destination):
|
|||||||
clean.save(output, "JPEG", quality=quality, optimize=True, progressive=True)
|
clean.save(output, "JPEG", quality=quality, optimize=True, progressive=True)
|
||||||
if output.tell() <= GALLERY_MAX_BYTES:
|
if output.tell() <= GALLERY_MAX_BYTES:
|
||||||
Path(destination).write_bytes(output.getvalue())
|
Path(destination).write_bytes(output.getvalue())
|
||||||
return
|
return taken_at
|
||||||
clean.thumbnail((max(1, int(clean.width * 0.85)), max(1, int(clean.height * 0.85))),
|
clean.thumbnail((max(1, int(clean.width * 0.85)), max(1, int(clean.height * 0.85))),
|
||||||
Image.Resampling.LANCZOS)
|
Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
def photo_entry(job, date, index, count, caption, highlight):
|
def photo_entry(job, date, index, caption, highlight):
|
||||||
# Give each photo its own collection document. Descending dates preserve the
|
# Each photo carries its own capture date so the gallery is chronological.
|
||||||
# selection order because the gallery renders the newest document first.
|
item_date = datetime.fromisoformat(date)
|
||||||
# Jekyll's collection ordering only preserves whole seconds. Give each item
|
|
||||||
# a distinct second so filenames cannot disturb the selected photo order.
|
|
||||||
item_date = datetime.fromisoformat(date) + timedelta(seconds=count - index)
|
|
||||||
title = f"photo-{job}" if index == 0 else f"photo-{job}-{index:02}"
|
title = f"photo-{job}" if index == 0 else f"photo-{job}-{index:02}"
|
||||||
content = ["---", "layout: post", f'title: "{title}"',
|
content = ["---", "layout: post", f'title: "{title}"',
|
||||||
f'date: "{item_date.isoformat()}"', "categories: art"]
|
f'date: "{item_date.isoformat()}"', "categories: art"]
|
||||||
@@ -255,6 +318,11 @@ def create_app(data_dir=None, token=None):
|
|||||||
highlight = request.form.get("highlight", "").strip()
|
highlight = request.form.get("highlight", "").strip()
|
||||||
if len(highlight) > 200:
|
if len(highlight) > 200:
|
||||||
abort(400, "Highlight name must be at most 200 characters")
|
abort(400, "Highlight name must be at most 200 characters")
|
||||||
|
try:
|
||||||
|
supplied_dates = (submitted_photo_dates(request.form.get("photo_dates", ""), len(photos))
|
||||||
|
if kind == "photo" else None)
|
||||||
|
except ValueError as error:
|
||||||
|
abort(400, str(error))
|
||||||
# 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())
|
||||||
if kind == "snippet":
|
if kind == "snippet":
|
||||||
@@ -263,12 +331,15 @@ def create_app(data_dir=None, token=None):
|
|||||||
try:
|
try:
|
||||||
(temporary / "originals").mkdir()
|
(temporary / "originals").mkdir()
|
||||||
(temporary / "images").mkdir()
|
(temporary / "images").mkdir()
|
||||||
|
capture_dates = []
|
||||||
for index, photo in enumerate(photos):
|
for index, photo in enumerate(photos):
|
||||||
raw = photo if isinstance(photo, bytes) else photo.read()
|
raw = photo if isinstance(photo, bytes) else photo.read()
|
||||||
digest.update(hashlib.sha256(raw).digest())
|
digest.update(hashlib.sha256(raw).digest())
|
||||||
(temporary / "originals" / str(index)).write_bytes(raw)
|
(temporary / "originals" / str(index)).write_bytes(raw)
|
||||||
try:
|
try:
|
||||||
web_image(raw, temporary / "images" / f"{index:02}.jpg")
|
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)
|
||||||
except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
|
except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
|
||||||
Image.DecompressionBombError, Image.DecompressionBombWarning,
|
Image.DecompressionBombError, Image.DecompressionBombWarning,
|
||||||
ImageCms.PyCMSError):
|
ImageCms.PyCMSError):
|
||||||
@@ -276,18 +347,30 @@ def create_app(data_dir=None, token=None):
|
|||||||
# Preserve existing IDs when omitted; named highlights participate in deduplication.
|
# Preserve existing IDs when omitted; named highlights participate in deduplication.
|
||||||
if highlight:
|
if highlight:
|
||||||
digest.update(b"\0highlight\0" + highlight.encode())
|
digest.update(b"\0highlight\0" + highlight.encode())
|
||||||
|
undated_job = digest.hexdigest()
|
||||||
|
if 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()
|
job = digest.hexdigest()
|
||||||
date = datetime.now(timezone.utc).isoformat()
|
date = datetime.now(timezone.utc).isoformat()
|
||||||
manifest = {"id": job, "date": date, "caption": caption,
|
manifest = {"id": job, "date": date, "caption": caption,
|
||||||
"highlight": highlight, "count": len(photos)}
|
"highlight": highlight, "count": len(photos)}
|
||||||
|
if (supplied_dates and undated_job != job and
|
||||||
|
(data / "submissions" / undated_job / "manifest.json").is_file()):
|
||||||
|
manifest["replaces"] = undated_job
|
||||||
if kind == "snippet":
|
if kind == "snippet":
|
||||||
manifest.update(kind=kind, text=text)
|
manifest.update(kind=kind, text=text)
|
||||||
atomic_json(temporary / "manifest.json", manifest)
|
atomic_json(temporary / "manifest.json", manifest)
|
||||||
if kind == "photo":
|
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")
|
||||||
|
capture_dates = distinct_photo_dates(capture_dates)
|
||||||
|
manifest["capture_dates"] = [taken.isoformat() for taken in capture_dates]
|
||||||
|
atomic_json(temporary / "manifest.json", manifest)
|
||||||
(temporary / "entries").mkdir()
|
(temporary / "entries").mkdir()
|
||||||
for index in range(len(photos)):
|
for index in range(len(photos)):
|
||||||
(temporary / "entries" / f"{index:02}.md").write_text(
|
(temporary / "entries" / f"{index:02}.md").write_text(
|
||||||
photo_entry(job, date, index, len(photos), caption, highlight))
|
photo_entry(job, capture_dates[index].isoformat(), index, caption, highlight))
|
||||||
# Retain the old filename for compatibility with publisher versions
|
# Retain the old filename for compatibility with publisher versions
|
||||||
# that predate one-document-per-photo submissions.
|
# that predate one-document-per-photo submissions.
|
||||||
content = [(temporary / "entries/00.md").read_text()]
|
content = [(temporary / "entries/00.md").read_text()]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -15,6 +16,13 @@ from posts import publish_post
|
|||||||
log = logging.getLogger("publisher")
|
log = logging.getLogger("publisher")
|
||||||
|
|
||||||
|
|
||||||
|
def entry_date(path):
|
||||||
|
match = re.search(r'^date: "(\d{4}-\d{2}-\d{2})', path.read_text(), re.MULTILINE)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Photo entry has no valid date: {path.name}")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
class Publisher:
|
class Publisher:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.data = Path(os.environ.get("PHOTO_DATA", "/data"))
|
self.data = Path(os.environ.get("PHOTO_DATA", "/data"))
|
||||||
@@ -50,6 +58,7 @@ class Publisher:
|
|||||||
self.state(job, "publishing")
|
self.state(job, "publishing")
|
||||||
try:
|
try:
|
||||||
outcomes = {}
|
outcomes = {}
|
||||||
|
replaced_photos = {}
|
||||||
if not (self.repo / ".git").is_dir():
|
if not (self.repo / ".git").is_dir():
|
||||||
self.git("clone", "--branch", self.branch, "--single-branch", "--",
|
self.git("clone", "--branch", self.branch, "--single-branch", "--",
|
||||||
self.remote, str(self.repo), directory=self.work)
|
self.remote, str(self.repo), directory=self.work)
|
||||||
@@ -85,6 +94,14 @@ class Publisher:
|
|||||||
continue
|
continue
|
||||||
entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md"
|
entry = Path("_art") / f"{manifest['date'][:10]}-photo-{job}.md"
|
||||||
images = Path("img/arts/uploads") / job
|
images = Path("img/arts/uploads") / job
|
||||||
|
replaces = manifest.get("replaces", "")
|
||||||
|
if re.fullmatch(r"[a-f0-9]{64}", replaces) and replaces != job:
|
||||||
|
for previous in (self.repo / "_art").glob(f"*-photo-{replaces}*.md"):
|
||||||
|
self.git("rm", "--ignore-unmatch", "--",
|
||||||
|
str(previous.relative_to(self.repo)))
|
||||||
|
previous_images = Path("img/arts/uploads") / replaces
|
||||||
|
self.git("rm", "-r", "--ignore-unmatch", "--", str(previous_images))
|
||||||
|
replaced_photos[job] = replaces
|
||||||
(self.repo / entry).parent.mkdir(parents=True, exist_ok=True)
|
(self.repo / entry).parent.mkdir(parents=True, exist_ok=True)
|
||||||
entries = submission / "entries"
|
entries = submission / "entries"
|
||||||
if entries.is_dir():
|
if entries.is_dir():
|
||||||
@@ -92,7 +109,7 @@ class Publisher:
|
|||||||
# is republished by a newer service version.
|
# is republished by a newer service version.
|
||||||
self.git("rm", "--ignore-unmatch", "--", str(entry))
|
self.git("rm", "--ignore-unmatch", "--", str(entry))
|
||||||
for source in sorted(entries.glob("*.md")):
|
for source in sorted(entries.glob("*.md")):
|
||||||
destination = Path("_art") / f"{manifest['date'][:10]}-photo-{job}-{source.stem}.md"
|
destination = Path("_art") / f"{entry_date(source)}-photo-{job}-{source.stem}.md"
|
||||||
shutil.copyfile(source, self.repo / destination)
|
shutil.copyfile(source, self.repo / destination)
|
||||||
self.git("add", "--", str(destination))
|
self.git("add", "--", str(destination))
|
||||||
else:
|
else:
|
||||||
@@ -106,6 +123,8 @@ class Publisher:
|
|||||||
commit = self.git("rev-parse", "HEAD")
|
commit = self.git("rev-parse", "HEAD")
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
self.state(job, "pushed", commit=commit, **outcomes.get(job, {}))
|
self.state(job, "pushed", commit=commit, **outcomes.get(job, {}))
|
||||||
|
if job in replaced_photos:
|
||||||
|
self.state(replaced_photos[job], "superseded", replaced_by=job)
|
||||||
# A subsequent revision replaces the old one in the rendered site.
|
# A subsequent revision replaces the old one in the rendered site.
|
||||||
for job, outcome in outcomes.items():
|
for job, outcome in outcomes.items():
|
||||||
job_date = json.loads((self.data / "submissions" / job / "manifest.json").read_text())["date"]
|
job_date = json.loads((self.data / "submissions" / job / "manifest.json").read_text())["date"]
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ def service(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def photo(color="red", format="JPEG", size=(40, 20), exif=None):
|
def photo(color="red", format="JPEG", size=(40, 20), exif=None):
|
||||||
|
if exif is None:
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[36867] = "2025:01:02 03:04:05"
|
||||||
|
exif[36881] = "+00:00"
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
Image.new("RGB", size, color).save(output, format, **({"exif": exif} if exif else {}))
|
Image.new("RGB", size, color).save(output, format, **({"exif": exif} if exif else {}))
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
@@ -64,8 +68,10 @@ def test_validation_is_all_or_nothing(service):
|
|||||||
def test_multi_megabyte_multipart_file(service):
|
def test_multi_megabyte_multipart_file(service):
|
||||||
_, client, data = service
|
_, client, data = service
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[36867] = "2025:01:02 03:04:05"
|
||||||
Image.frombytes("RGB", (1800, 1800), random.Random(2).randbytes(1800 * 1800 * 3)).save(
|
Image.frombytes("RGB", (1800, 1800), random.Random(2).randbytes(1800 * 1800 * 3)).save(
|
||||||
output, "JPEG", quality=95)
|
output, "JPEG", quality=95, exif=exif)
|
||||||
assert len(output.getvalue()) > 3 * 1024 * 1024
|
assert len(output.getvalue()) > 3 * 1024 * 1024
|
||||||
response = upload(client, [output.getvalue()])
|
response = upload(client, [output.getvalue()])
|
||||||
assert response.status_code == 202, response.json
|
assert response.status_code == 202, response.json
|
||||||
@@ -138,6 +144,7 @@ def test_orientation_metadata_and_heic(service):
|
|||||||
exif = Image.Exif()
|
exif = Image.Exif()
|
||||||
exif[274] = 6
|
exif[274] = 6
|
||||||
exif[270] = "private metadata"
|
exif[270] = "private metadata"
|
||||||
|
exif[36867] = "2025:01:02 03:04:05"
|
||||||
for raw in [photo(exif=exif, size=(3000, 1500)), photo(format="HEIF")]:
|
for raw in [photo(exif=exif, size=(3000, 1500)), photo(format="HEIF")]:
|
||||||
response = upload(client, [raw])
|
response = upload(client, [raw])
|
||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
@@ -149,6 +156,61 @@ def test_orientation_metadata_and_heic(service):
|
|||||||
assert image.height > image.width
|
assert image.height > image.width
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_date_comes_from_original_metadata(service):
|
||||||
|
_, client, data = service
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[36867] = "2025:04:18 14:23:11"
|
||||||
|
exif[36881] = "+02:00"
|
||||||
|
|
||||||
|
response = upload(client, [photo(exif=exif)])
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
entry = (data / "submissions" / response.json["id"] / "entries/00.md").read_text()
|
||||||
|
assert 'date: "2025-04-18T14:23:11+02:00"' in entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_dates_can_come_from_photos_library_metadata(service):
|
||||||
|
_, client, data = service
|
||||||
|
response = client.post("/api/photos", headers=AUTH, data={
|
||||||
|
"photos": [
|
||||||
|
(io.BytesIO(photo("red")), "first.jpg"),
|
||||||
|
(io.BytesIO(photo("blue")), "second.jpg"),
|
||||||
|
],
|
||||||
|
"photo_dates": "2025-04-18T14:23:11+02:00\n2025-04-19T09:10:05+02:00",
|
||||||
|
})
|
||||||
|
assert response.status_code == 202
|
||||||
|
entries = sorted((data / "submissions" / response.json["id"] / "entries").glob("*.md"))
|
||||||
|
assert 'date: "2025-04-18T14:23:11+02:00"' in entries[0].read_text()
|
||||||
|
assert 'date: "2025-04-19T09:10:05+02:00"' in entries[1].read_text()
|
||||||
|
|
||||||
|
invalid = client.post("/api/photos", headers=AUTH, data={
|
||||||
|
"photos": [(io.BytesIO(photo()), "photo.jpg")],
|
||||||
|
"photo_dates": "not-a-date",
|
||||||
|
})
|
||||||
|
assert invalid.status_code == 400
|
||||||
|
assert "valid ISO dates" in invalid.json["error"]
|
||||||
|
|
||||||
|
missing = upload(client, [photo(exif=Image.Exif())])
|
||||||
|
assert missing.status_code == 400
|
||||||
|
assert "capture date is missing" in missing.json["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dated_retry_replaces_the_same_undated_upload(service):
|
||||||
|
_, client, data = service
|
||||||
|
raw = photo()
|
||||||
|
original = upload(client, [raw], "Same caption", highlight="Trip").json
|
||||||
|
replacement = client.post("/api/photos", headers=AUTH, data={
|
||||||
|
"photos": [(io.BytesIO(raw), "photo.jpg")],
|
||||||
|
"caption": "Same caption",
|
||||||
|
"highlight": "Trip",
|
||||||
|
"photo_dates": "2025-04-18T14:23:11+02:00",
|
||||||
|
}).json
|
||||||
|
|
||||||
|
assert replacement["id"] != original["id"]
|
||||||
|
manifest = json.loads((data / "submissions" / replacement["id"] / "manifest.json").read_text())
|
||||||
|
assert manifest["replaces"] == original["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_optional_highlight_preserves_existing_upload_ids(service):
|
def test_optional_highlight_preserves_existing_upload_ids(service):
|
||||||
_, client, data = service
|
_, client, data = service
|
||||||
raw = photo()
|
raw = photo()
|
||||||
@@ -239,6 +301,40 @@ def test_git_push_and_crash_recovery(publisher, service, monkeypatch, tmp_path):
|
|||||||
assert client.get(response.json["status_url"], headers=AUTH).json["status"] == "published"
|
assert client.get(response.json["status_url"], headers=AUTH).json["status"] == "published"
|
||||||
|
|
||||||
|
|
||||||
|
def test_publisher_names_photo_entry_from_capture_date(publisher, service):
|
||||||
|
run, remote, client = publisher
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[36867] = "2025:04:18 14:23:11"
|
||||||
|
response = upload(client, [photo(exif=exif)])
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
paths = git(remote, "ls-tree", "-r", "--name-only", "master")
|
||||||
|
assert f'_art/2025-04-18-photo-{response.json["id"]}-00.md' in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_publisher_replaces_an_undated_version_of_the_same_photos(publisher, service):
|
||||||
|
run, remote, client = publisher
|
||||||
|
raw = photo()
|
||||||
|
original = upload(client, [raw], "Same caption", highlight="Trip").json
|
||||||
|
assert run().returncode == 0
|
||||||
|
replacement = client.post("/api/photos", headers=AUTH, data={
|
||||||
|
"photos": [(io.BytesIO(raw), "photo.jpg")],
|
||||||
|
"caption": "Same caption",
|
||||||
|
"highlight": "Trip",
|
||||||
|
"photo_dates": "2025-04-18T14:23:11+02:00",
|
||||||
|
}).json
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
paths = git(remote, "ls-tree", "-r", "--name-only", "master")
|
||||||
|
assert f"photo-{original['id']}" not in paths
|
||||||
|
assert f"img/arts/uploads/{original['id']}" not in paths
|
||||||
|
assert f'_art/2025-04-18-photo-{replacement["id"]}-00.md' in paths
|
||||||
|
|
||||||
|
|
||||||
def test_rejected_push_can_retry(publisher):
|
def test_rejected_push_can_retry(publisher):
|
||||||
run, remote, client = publisher
|
run, remote, client = publisher
|
||||||
head = git(remote, "rev-parse", "master")
|
head = git(remote, "rev-parse", "master")
|
||||||
|
|||||||
Reference in New Issue
Block a user