From 7c8723d2ad2885998255705ba1013f2d8c1451b2 Mon Sep 17 00:00:00 2001 From: Mahdi Dibaiee Date: Sat, 19 Sep 2026 15:54:33 +0330 Subject: [PATCH] Accept single snippet photos and derive post IDs from titles --- photo-upload/README.md | 11 +++++++++++ photo-upload/app.py | 5 +++++ photo-upload/posts.py | 25 ++++++++++++++++++------- photo-upload/tests/test_posts.py | 25 ++++++++++++++++++++++++- photo-upload/tests/test_upload.py | 19 +++++++++++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/photo-upload/README.md b/photo-upload/README.md index 459b786..25b31e6 100644 --- a/photo-upload/README.md +++ b/photo-upload/README.md @@ -188,6 +188,8 @@ Create **Publish snippet**: - Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos). - `text`: **Text**, the combined shared text. - `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 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 @@ -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 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 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`. `GET /api/snippets/` checks publication, and `POST /api/snippets//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. 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 The publisher compares the body against existing Markdown posts, ignoring front diff --git a/photo-upload/app.py b/photo-upload/app.py index 1545a43..1bc24ee 100644 --- a/photo-upload/app.py +++ b/photo-upload/app.py @@ -184,6 +184,11 @@ def create_app(data_dir=None, token=None): def upload(): kind = "snippet" if request.path == "/api/snippets" else "photo" 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") if archives: if photos or len(archives) != 1: diff --git a/photo-upload/posts.py b/photo-upload/posts.py index b96df61..893fc6f 100644 --- a/photo-upload/posts.py +++ b/photo-upload/posts.py @@ -1,7 +1,6 @@ """Markdown import with conservative content matching and explicit ID overrides.""" from difflib import SequenceMatcher from datetime import date, datetime -import hashlib import re import unicodedata @@ -28,6 +27,17 @@ def filename_key(filename): 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): 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") @@ -69,7 +79,7 @@ def parse_upload(filename, raw): raise ValueError("date must be an ISO date or timestamp") from None clean["date"] = str(value) 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") if "permalink" in clean and not re.fullmatch(r"/[\w/-]+/", clean["permalink"]): 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:]) if len(candidates) > 1: 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: 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. 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) + "/" else: metadata = dict(incoming["metadata"]) metadata.setdefault("date", manifest["date"]) - slug = re.sub(r"[^\w-]+", "-", incoming["metadata"]["title"].lower()).strip("-")[:80] or "post" - permalink = metadata.get("permalink", f"/{slug}-{identity[:8]}/") + permalink = metadata.get("permalink", f"/{identity}/") entry = repo / "_posts" / f"{str(metadata['date'])[:10]}-upload-{identity}.md" if entry.exists(): 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. for other in list(repo.glob("*.md")) + list(repo.glob("*.html")): 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("/"): raise ValueError("Another page already uses this permalink") 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"], render_with_liquid=False) entry.parent.mkdir(parents=True, exist_ok=True) diff --git a/photo-upload/tests/test_posts.py b/photo-upload/tests/test_posts.py index ff51600..f122389 100644 --- a/photo-upload/tests/test_posts.py +++ b/photo-upload/tests/test_posts.py @@ -2,7 +2,7 @@ import io import json 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)) @@ -32,6 +32,7 @@ def test_content_update_preserves_url_date_and_no_duplicate(publisher, service, assert run().returncode == 0 status = client.get(first.json["status_url"], headers=AUTH).json 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") result = run() 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(BODY, BODY.replace("river", "lake", 1)) >= 0.92 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 diff --git a/photo-upload/tests/test_upload.py b/photo-upload/tests/test_upload.py index 58c8abf..a36b577 100644 --- a/photo-upload/tests/test_upload.py +++ b/photo-upload/tests/test_upload.py @@ -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 +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): run, remote, client = publisher _, _, data = service