Accept single snippet photos and derive post IDs from titles
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user