Accept single snippet photos and derive post IDs from titles

This commit is contained in:
2026-09-19 15:54:33 +03:30
parent 2e90e82502
commit 7c8723d2ad
5 changed files with 77 additions and 8 deletions
+11
View File
@@ -188,6 +188,8 @@ Create **Publish snippet**:
- Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos). - Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos).
- `text`: **Text**, the combined shared text. - `text`: **Text**, the combined shared text.
- `caption`: **Text**, the optional note. - `caption`: **Text**, the optional note.
- For a single image, `photo`: **File**, the shared image. No archive is needed.
Use only one of `photo`, `photos`, or `archive` per request.
- For images, **Make Archive** (ZIP) from **Snippet Images** first, and send its - For images, **Make Archive** (ZIP) from **Snippet Images** first, and send its
output as `archive`, type **File**. For text-only requests, omit this field. output as `archive`, type **File**. For text-only requests, omit this field.
Use an **If** on whether there are images to choose between the form with Use an **If** on whether there are images to choose between the form with
@@ -204,6 +206,8 @@ branch and `archive` field; add the image branch when that works on your phone.
`POST /api/snippets` accepts up to 20,000 characters of `text`, an optional `POST /api/snippets` accepts up to 20,000 characters of `text`, an optional
4,000-character `caption`, and up to 20 images in a ZIP `archive` (or repeated `photos` parts), within 4,000-character `caption`, and up to 20 images in a ZIP `archive` (or repeated `photos` parts), within
the existing 100 MiB request limit. At least text or an image is required. the existing 100 MiB request limit. At least text or an image is required.
The singular `photo` field accepts one image and produces the same submission
as sending that image in `photos`.
It returns `id`, `count` (image count), `url`, `status_url`, and `status`. It returns `id`, `count` (image count), `url`, `status_url`, and `status`.
`GET /api/snippets/<id>` checks publication, and `GET /api/snippets/<id>` checks publication, and
`POST /api/snippets/<id>/retry` retries failed Git publication. Identical text, `POST /api/snippets/<id>/retry` retries failed Git publication. Identical text,
@@ -237,6 +241,13 @@ filename is used if there is no heading. Optional YAML front matter supports
preserved; Liquid template evaluation is disabled for imported post bodies. preserved; Liquid template evaluation is disabled for imported post bodies.
The original file is retained privately in the service volume. The original file is retained privately in the service volume.
New posts derive `post_id` from the title: `My New Post!` becomes `my-new-post`.
Spaces, underscores, and punctuation become single dashes; Unicode letters are
preserved. The default URL is `/my-new-post/`. Explicit `post_id` values still
override this default. Updates keep the existing ID and URL even if the title
changes. If unrelated content has the same title-derived ID, publication fails
instead of overwriting it; choose another title or an explicit ID.
### Automatic updates by content ### Automatic updates by content
The publisher compares the body against existing Markdown posts, ignoring front The publisher compares the body against existing Markdown posts, ignoring front
+5
View File
@@ -184,6 +184,11 @@ def create_app(data_dir=None, token=None):
def upload(): def upload():
kind = "snippet" if request.path == "/api/snippets" else "photo" kind = "snippet" if request.path == "/api/snippets" else "photo"
photos = request.files.getlist("photos") photos = request.files.getlist("photos")
if kind == "snippet" and "photo" in request.files:
single = request.files.getlist("photo")
if len(single) != 1 or photos:
abort(400, "Send one photo, photos, or an archive; do not mix image fields")
photos = single
archives = request.files.getlist("archive") archives = request.files.getlist("archive")
if archives: if archives:
if photos or len(archives) != 1: if photos or len(archives) != 1:
+18 -7
View File
@@ -1,7 +1,6 @@
"""Markdown import with conservative content matching and explicit ID overrides.""" """Markdown import with conservative content matching and explicit ID overrides."""
from difflib import SequenceMatcher from difflib import SequenceMatcher
from datetime import date, datetime from datetime import date, datetime
import hashlib
import re import re
import unicodedata import unicodedata
@@ -28,6 +27,17 @@ def filename_key(filename):
return unicodedata.normalize("NFC", filename).casefold() return unicodedata.normalize("NFC", filename).casefold()
def title_id(title):
slug = re.sub(r"[\W_]+", "-", unicodedata.normalize("NFKC", title).lower()).strip("-")
slug = slug[:80].rstrip("-")
# Leave room for the date and prefix in the filesystem's byte-length limit.
while len(slug.encode("utf-8")) > 160:
slug = slug[:-1].rstrip("-")
if not slug:
raise ValueError("The post title must contain a letter or number")
return slug
def parse_upload(filename, raw): def parse_upload(filename, raw):
if not filename or "/" in filename or "\\" in filename or not filename.lower().endswith((".md", ".markdown")): if not filename or "/" in filename or "\\" in filename or not filename.lower().endswith((".md", ".markdown")):
raise ValueError("Share one .md or .markdown file with a plain filename") raise ValueError("Share one .md or .markdown file with a plain filename")
@@ -69,7 +79,7 @@ def parse_upload(filename, raw):
raise ValueError("date must be an ISO date or timestamp") from None raise ValueError("date must be an ISO date or timestamp") from None
clean["date"] = str(value) clean["date"] = str(value)
explicit_id = clean.pop("post_id", None) explicit_id = clean.pop("post_id", None)
if explicit_id is not None and not re.fullmatch(r"[a-zA-Z0-9_-]{1,80}", explicit_id): if explicit_id is not None and (not re.fullmatch(r"[\w-]{1,80}", explicit_id) or len(explicit_id.encode()) > 160):
raise ValueError("post_id must be 1–80 letters, digits, underscores, or hyphens") raise ValueError("post_id must be 1–80 letters, digits, underscores, or hyphens")
if "permalink" in clean and not re.fullmatch(r"/[\w/-]+/", clean["permalink"]): if "permalink" in clean and not re.fullmatch(r"/[\w/-]+/", clean["permalink"]):
raise ValueError("permalink must be a local path such as /my-post/") raise ValueError("permalink must be a local path such as /my-post/")
@@ -109,21 +119,22 @@ def publish_post(repo, submission, manifest):
candidates.append(scored[0][1:]) candidates.append(scored[0][1:])
if len(candidates) > 1: if len(candidates) > 1:
raise ValueError("More than one post matches this post ID") raise ValueError("More than one post matches this post ID")
identity = incoming["explicit_id"] or manifest["id"] identity = incoming["explicit_id"] or title_id(incoming["metadata"]["title"])
if candidates: if candidates:
entry, metadata = candidates[0] entry, metadata = candidates[0]
identity = metadata.get("upload_id", identity) identity = metadata.get("upload_id") or metadata.get("post_id") or title_id(metadata.get("title", entry.stem))
# Keep the public URL and original publication date during updates. # Keep the public URL and original publication date during updates.
metadata.update({key: value for key, value in incoming["metadata"].items() if key not in {"date", "permalink"}}) metadata.update({key: value for key, value in incoming["metadata"].items() if key not in {"date", "permalink"}})
permalink = metadata.get("permalink") or "/" + re.sub(r"^\d{4}-\d{2}-\d{2}-", "", entry.stem) + "/" permalink = metadata.get("permalink") or "/" + re.sub(r"^\d{4}-\d{2}-\d{2}-", "", entry.stem) + "/"
else: else:
metadata = dict(incoming["metadata"]) metadata = dict(incoming["metadata"])
metadata.setdefault("date", manifest["date"]) metadata.setdefault("date", manifest["date"])
slug = re.sub(r"[^\w-]+", "-", incoming["metadata"]["title"].lower()).strip("-")[:80] or "post" permalink = metadata.get("permalink", f"/{identity}/")
permalink = metadata.get("permalink", f"/{slug}-{identity[:8]}/")
entry = repo / "_posts" / f"{str(metadata['date'])[:10]}-upload-{identity}.md" entry = repo / "_posts" / f"{str(metadata['date'])[:10]}-upload-{identity}.md"
if entry.exists(): if entry.exists():
raise ValueError("Post identity conflicts with another file") raise ValueError("Post identity conflicts with another file")
if any(identity in (record.get("upload_id"), record.get("post_id")) for _, record in records):
raise ValueError("Another post already uses this title-derived ID; use a different title or explicit post_id")
# Avoid taking over another page's URL, including non-post pages. # Avoid taking over another page's URL, including non-post pages.
for other in list(repo.glob("*.md")) + list(repo.glob("*.html")): for other in list(repo.glob("*.md")) + list(repo.glob("*.html")):
other_metadata, _ = split_markdown(other.read_text()) other_metadata, _ = split_markdown(other.read_text())
@@ -135,7 +146,7 @@ def publish_post(repo, submission, manifest):
if url and str(url).strip("/") == permalink.strip("/"): if url and str(url).strip("/") == permalink.strip("/"):
raise ValueError("Another page already uses this permalink") raise ValueError("Another page already uses this permalink")
permalink = "/" + str(permalink).strip("/") + "/" permalink = "/" + str(permalink).strip("/") + "/"
metadata.update(layout="post", permalink=permalink, upload_id=identity, metadata.update(layout="post", permalink=permalink, upload_id=identity, post_id=identity,
upload_filename=incoming["key"], upload_revision=manifest["id"], upload_filename=incoming["key"], upload_revision=manifest["id"],
render_with_liquid=False) render_with_liquid=False)
entry.parent.mkdir(parents=True, exist_ok=True) entry.parent.mkdir(parents=True, exist_ok=True)
+24 -1
View File
@@ -2,7 +2,7 @@ import io
import json import json
from test_upload import AUTH, git, publisher, service from test_upload import AUTH, git, publisher, service
from posts import content_similarity from posts import content_similarity, title_id
BODY = "\n\n".join(f"Paragraph {i}: I walked beside the river and considered how memory changes our understanding of a place. The details matter more than the names we give them." for i in range(12)) BODY = "\n\n".join(f"Paragraph {i}: I walked beside the river and considered how memory changes our understanding of a place. The details matter more than the names we give them." for i in range(12))
@@ -32,6 +32,7 @@ def test_content_update_preserves_url_date_and_no_duplicate(publisher, service,
assert run().returncode == 0 assert run().returncode == 0
status = client.get(first.json["status_url"], headers=AUTH).json status = client.get(first.json["status_url"], headers=AUTH).json
assert status["action"] == "created" and status["url"] == "/my-walk/" assert status["action"] == "created" and status["url"] == "/my-walk/"
assert status["post_id"] == "my-walk"
second = send(client, "# My revised walk\n\n" + BODY.replace("beside the river", "along the river", 1), "renamed.md") second = send(client, "# My revised walk\n\n" + BODY.replace("beside the river", "along the river", 1), "renamed.md")
result = run() result = run()
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -79,3 +80,25 @@ def test_similarity_requires_substantial_unambiguous_content():
assert content_similarity("short shared title", "short shared title") == 0 assert content_similarity("short shared title", "short shared title") == 0
assert content_similarity(BODY, BODY.replace("river", "lake", 1)) >= 0.92 assert content_similarity(BODY, BODY.replace("river", "lake", 1)) >= 0.92
assert content_similarity(BODY, "Completely unrelated writing.") == 0 assert content_similarity(BODY, "Completely unrelated writing.") == 0
def test_title_derived_id_and_permalink(publisher):
run, _, client = publisher
response = send(client, "# My New_Post: Hello, World!\n\n" + BODY)
assert run().returncode == 0
state = client.get(response.json["status_url"], headers=AUTH).json
assert state["post_id"] == "my-new-post-hello-world"
assert state["url"] == "/my-new-post-hello-world/"
assert title_id(" سفر به تهران! ") == "سفر-به-تهران"
assert len(title_id("漢" * 100).encode()) <= 160
def test_same_title_does_not_overwrite_unrelated_content(publisher):
run, remote, client = publisher
send(client, "# Shared title\n\n" + BODY)
assert run().returncode == 0
head = git(remote, "rev-parse", "master")
response = send(client, "# Shared title\n\n" + "An unrelated essay about distant stars. " * 20)
assert run().returncode != 0
assert client.get(response.json["status_url"], headers=AUTH).json["status"] == "failed"
assert git(remote, "rev-parse", "master") == head
+19
View File
@@ -266,6 +266,25 @@ def test_snippet_intake_validation_and_literal_text(service):
assert client.post("/api/snippets", headers=AUTH, data={"photos": (io.BytesIO(b"bad"), "bad.jpg")}).status_code == 400 assert client.post("/api/snippets", headers=AUTH, data={"photos": (io.BytesIO(b"bad"), "bad.jpg")}).status_code == 400
def test_snippet_single_photo_alias(service):
_, client, data = service
raw = photo()
response = client.post("/api/snippets", headers=AUTH, data={
"photo": (io.BytesIO(raw), "photo.jpg"), "text": "A note",
})
assert response.status_code == 202, response.json
assert response.json["count"] == 1
assert (data / "submissions" / response.json["id"] / "originals/0").read_bytes() == raw
duplicate = client.post("/api/snippets", headers=AUTH, data={
"photos": (io.BytesIO(raw), "photo.jpg"), "text": "A note",
})
assert duplicate.json["id"] == response.json["id"]
assert duplicate.status_code == 200
assert client.post("/api/snippets", headers=AUTH, data={
"photo": [(io.BytesIO(raw), "one.jpg"), (io.BytesIO(raw), "two.jpg")],
}).status_code == 400
def test_snippet_prepend_images_recovery_and_receipt(publisher, service, monkeypatch, tmp_path): def test_snippet_prepend_images_recovery_and_receipt(publisher, service, monkeypatch, tmp_path):
run, remote, client = publisher run, remote, client = publisher
_, _, data = service _, _, data = service