- Invite flow: admin issue/revoke on join requests, public single-use claim links (hash-only tokens, 7-day expiry, no state reveal), claim creates the Member account and marks the request accepted - Gallery: admin status page plus resync endpoint; sync refreshes thumbnails whose content changed; tools/gallery contract and checker - Docs: public content page, admin-only operator runbook, api.md invite/gallery sections, all routes in the live API docs, docs reachability gate test - Screenshots cover the new pages; version 1.0.18
104 lines
4.1 KiB
Python
104 lines
4.1 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
"""Validate gallery source files against the catalogue contract.
|
|
|
|
Usage:
|
|
python tools/gallery/check.py [media_dir]
|
|
|
|
Checks every flyer-*.jpg and meme-*.jpg in the media directory (default:
|
|
the repo static/media tree) plus every entry of molodetz.gallery.GALLERY:
|
|
|
|
- name is lowercase, starts with flyer- or meme-, ends in .jpg
|
|
- flyers are 1080x1350, memes are 1080x1080 (1280x720 legacy memes warn)
|
|
- every source file is catalogued or retired, every catalogue entry has a file (repo tree only; scratch dirs get warnings)
|
|
- catalogue captions are non-empty English without em or en dashes
|
|
- no two catalogued works are perceptual near-duplicates (sync would skip one)
|
|
|
|
Exit code 0 when clean, 1 when problems are found. Warnings do not fail.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from PIL import Image # noqa: E402
|
|
|
|
import imagehash # noqa: E402
|
|
|
|
from molodetz.gallery import DUPLICATE_DISTANCE, GALLERY, RETIRED_GALLERY # noqa: E402
|
|
|
|
DIMENSIONS = {"flyer": (1080, 1350), "meme": (1080, 1080)}
|
|
LEGACY_MEME = (1280, 720)
|
|
|
|
|
|
def check(media_dir, strict=True):
|
|
errors, warnings = [], []
|
|
catalogued = {filename: (kind, caption) for filename, kind, caption in GALLERY}
|
|
retired = set(RETIRED_GALLERY)
|
|
files = sorted(path for path in media_dir.iterdir() if path.suffix.lower() == ".jpg" and path.stem.split("-")[0].lower() in ("flyer", "meme"))
|
|
seen = []
|
|
for path in files:
|
|
name = path.name
|
|
if name != name.lower():
|
|
errors.append(f"{name}: name must be lowercase")
|
|
kind = "flyer" if name.lower().startswith("flyer-") else "meme"
|
|
if name not in catalogued and name not in retired:
|
|
if strict:
|
|
errors.append(f"{name}: present on disk but missing from GALLERY")
|
|
continue
|
|
warnings.append(f"{name}: not catalogued yet, add a GALLERY entry on handoff")
|
|
caption = ""
|
|
elif name in retired:
|
|
continue
|
|
else:
|
|
expected_kind, caption = catalogued[name]
|
|
if expected_kind != kind:
|
|
errors.append(f"{name}: catalogued as {expected_kind}, prefix says {kind}")
|
|
if strict and (not caption or not caption.strip()):
|
|
errors.append(f"{name}: catalogue caption is empty")
|
|
elif "\u2014" in caption or "\u2013" in caption:
|
|
errors.append(f"{name}: caption contains an em or en dash")
|
|
with Image.open(path) as image:
|
|
size = image.size
|
|
digest = imagehash.phash(image)
|
|
expected = DIMENSIONS[kind]
|
|
if size != expected:
|
|
if kind == "meme" and size == LEGACY_MEME:
|
|
warnings.append(f"{name}: legacy 1280x720 size, new memes must be 1080x1080")
|
|
else:
|
|
errors.append(f"{name}: size {size[0]}x{size[1]}, expected {expected[0]}x{expected[1]}")
|
|
for other_name, other_digest in seen:
|
|
if digest - other_digest <= DUPLICATE_DISTANCE:
|
|
errors.append(f"{name}: near-duplicate of {other_name}, sync would skip it")
|
|
seen.append((name, digest))
|
|
on_disk = {path.name for path in files}
|
|
if strict:
|
|
for filename in catalogued:
|
|
if filename not in on_disk:
|
|
errors.append(f"{filename}: catalogued in GALLERY but missing from {media_dir}")
|
|
return errors, warnings
|
|
|
|
|
|
def main(argv):
|
|
default_dir = REPO_ROOT / "molodetz" / "static" / "media"
|
|
media_dir = Path(argv[1]) if len(argv) > 1 else default_dir
|
|
if not media_dir.is_dir():
|
|
print(f"missing directory: {media_dir}")
|
|
return 1
|
|
strict = media_dir.resolve() == default_dir.resolve()
|
|
errors, warnings = check(media_dir, strict=strict)
|
|
for line in warnings:
|
|
print(f"warn: {line}")
|
|
for line in errors:
|
|
print(f"error: {line}")
|
|
if errors:
|
|
print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
|
|
return 1
|
|
print(f"clean: {media_dir} ({len(warnings)} warning(s))")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|