Files
retoor 9fb4d65787 Member invites, gallery resync, content and operator docs
- 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
2026-10-06 02:56:08 +02:00

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))