Release: cover page at publication, sponsor logo approval
Browse files- register --live adds the Institute's cover edition beside the published
PDF in the same push. A cover it cannot make is reported in the log, the
state and the curator ping, and the page still publishes.
- The cover tool handles papers registered under the Institute prefix as
well as earlier records. The cover names the journal, volume, article
number, dates and licence with the page's own citation and matches the
manuscript's page size, and the tool checks the result before replacing
anything.
- Sponsor logo uploads are held for the curator's approval; --apply reviews
a held upload and publishes it.
- crossref_deposit.py +76 -1
- intake/scripts/test_cover_pdf.py +283 -0
- intake/scripts/test_sponsor_logo_hold.py +164 -0
- intake/sponsor_logo.py +211 -82
- tools/wrap-paper-pdf.py +428 -255
crossref_deposit.py
CHANGED
|
@@ -69,6 +69,8 @@ LICENSE_URLS = {
|
|
| 69 |
"cc-by-sa-4.0": "https://creativecommons.org/licenses/by-sa/4.0/",
|
| 70 |
"cc0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 71 |
}
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def _write_json_atomic(path, data) -> None:
|
|
@@ -743,7 +745,8 @@ def register(sub_dir: Path, *, live: bool = False, override_window: bool = False
|
|
| 743 |
1. re-stage the XML under the reserved DOI: publication_date = today and the
|
| 744 |
landing slug re-derived -- the deposit describes registration day
|
| 745 |
2. Crossref deposit; poll until OUR DOI reads Success -> crossref_registered_at
|
| 746 |
-
3. /publications push (+ PDF, + public review record)
|
|
|
|
| 747 |
4. Zenodo archive publish (preflight: draft carries our DOI) -> zenodo_published_at
|
| 748 |
5. author "published" notice DRAFTED to Gmail -> author_drafted_at
|
| 749 |
6. one curator ping that says what actually happened
|
|
@@ -865,6 +868,7 @@ def register(sub_dir: Path, *, live: bool = False, override_window: bool = False
|
|
| 865 |
_save_staged()
|
| 866 |
state = json.loads(state_p.read_text())
|
| 867 |
outcome["landing"] = state.get("publications_url")
|
|
|
|
| 868 |
|
| 869 |
# 4. Zenodo archive under the same DOI
|
| 870 |
if not ck.get("zenodo_published_at"):
|
|
@@ -905,6 +909,7 @@ def register(sub_dir: Path, *, live: bool = False, override_window: bool = False
|
|
| 905 |
# 6. the truthful ping
|
| 906 |
_ping(f"DOI REGISTERED for {sub_dir.name}: https://doi.org/{doi}\n"
|
| 907 |
f"Landing: {outcome.get('landing') or '(publications not configured)'}\n"
|
|
|
|
| 908 |
f"Zenodo archive: {outcome.get('archive')}\n"
|
| 909 |
f"ICSAC Zenodo community: {outcome.get('community') or 'not attempted'}\n"
|
| 910 |
f"Author 'published' email: {outcome['author_draft']}"
|
|
@@ -976,6 +981,64 @@ def _landing(slug: str) -> str:
|
|
| 976 |
return publications.publications_url(slug)
|
| 977 |
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dict:
|
| 980 |
"""Upsert /publications, copy the PDF to the website tree, stage the public
|
| 981 |
review, push. Mirrors publish_watcher._register_published for Zenodo."""
|
|
@@ -1017,6 +1080,7 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
|
|
| 1017 |
log(" crossref: publications registry not configured; landing page NOT pushed")
|
| 1018 |
return {}
|
| 1019 |
extra = []
|
|
|
|
| 1020 |
if publications.WEBSITE_REPO:
|
| 1021 |
pdf_src = sub_dir / "paper.pdf"
|
| 1022 |
if pdf_src.exists():
|
|
@@ -1024,6 +1088,15 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
|
|
| 1024 |
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 1025 |
shutil.copyfile(pdf_src, dest)
|
| 1026 |
extra.append(str(dest))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
review_md, rqc_md = publications.stage_public_review_for_slug(
|
| 1028 |
sub_id, entry["slug"], config.REVIEWS_DIR, approved=True) # (review_key, slug, dir) -- same call as publish_watcher
|
| 1029 |
# The public review record travels in the same commit as the registry entry;
|
|
@@ -1061,6 +1134,8 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
|
|
| 1061 |
state = json.loads(state_p.read_text()) if state_p.exists() else {}
|
| 1062 |
state["publications_slug"] = entry["slug"]
|
| 1063 |
state["publications_url"] = publications.publications_url(entry["slug"])
|
|
|
|
|
|
|
| 1064 |
_write_json_atomic(state_p, state)
|
| 1065 |
log(f" crossref: publications entry pushed -> {state['publications_url']}")
|
| 1066 |
return entry
|
|
|
|
| 69 |
"cc-by-sa-4.0": "https://creativecommons.org/licenses/by-sa/4.0/",
|
| 70 |
"cc0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 71 |
}
|
| 72 |
+
# Adds the 1-page ICSAC cover: public/papers/<sub_id>.pdf -> <sub_id>-icsac.pdf.
|
| 73 |
+
COVER_TOOL = Path(_REPO_ROOT) / "tools" / "wrap-paper-pdf.py"
|
| 74 |
|
| 75 |
|
| 76 |
def _write_json_atomic(path, data) -> None:
|
|
|
|
| 745 |
1. re-stage the XML under the reserved DOI: publication_date = today and the
|
| 746 |
landing slug re-derived -- the deposit describes registration day
|
| 747 |
2. Crossref deposit; poll until OUR DOI reads Success -> crossref_registered_at
|
| 748 |
+
3. /publications push (+ PDF and its ICSAC cover edition, + public review record)
|
| 749 |
+
-> publications_pushed_at
|
| 750 |
4. Zenodo archive publish (preflight: draft carries our DOI) -> zenodo_published_at
|
| 751 |
5. author "published" notice DRAFTED to Gmail -> author_drafted_at
|
| 752 |
6. one curator ping that says what actually happened
|
|
|
|
| 868 |
_save_staged()
|
| 869 |
state = json.loads(state_p.read_text())
|
| 870 |
outcome["landing"] = state.get("publications_url")
|
| 871 |
+
outcome["cover"] = _cover_line(state.get("cover"), state.get("publications_slug"))
|
| 872 |
|
| 873 |
# 4. Zenodo archive under the same DOI
|
| 874 |
if not ck.get("zenodo_published_at"):
|
|
|
|
| 909 |
# 6. the truthful ping
|
| 910 |
_ping(f"DOI REGISTERED for {sub_dir.name}: https://doi.org/{doi}\n"
|
| 911 |
f"Landing: {outcome.get('landing') or '(publications not configured)'}\n"
|
| 912 |
+
f"Cover page: {outcome.get('cover')}\n"
|
| 913 |
f"Zenodo archive: {outcome.get('archive')}\n"
|
| 914 |
f"ICSAC Zenodo community: {outcome.get('community') or 'not attempted'}\n"
|
| 915 |
f"Author 'published' email: {outcome['author_draft']}"
|
|
|
|
| 981 |
return publications.publications_url(slug)
|
| 982 |
|
| 983 |
|
| 984 |
+
def _deposited_dates(xml_path: Path) -> tuple[Optional[_dt.date], Optional[_dt.date]]:
|
| 985 |
+
"""(acceptance_date, publication_date) exactly as deposited: the first
|
| 986 |
+
publication_date with a day is the article's (the issue's carries a year only)."""
|
| 987 |
+
def _d(el) -> Optional[_dt.date]:
|
| 988 |
+
try:
|
| 989 |
+
return _dt.date(int(el.findtext(_q("year"))), int(el.findtext(_q("month"))), int(el.findtext(_q("day"))))
|
| 990 |
+
except (TypeError, ValueError):
|
| 991 |
+
return None
|
| 992 |
+
try:
|
| 993 |
+
root = ET.parse(xml_path).getroot()
|
| 994 |
+
except (OSError, ET.ParseError):
|
| 995 |
+
return None, None
|
| 996 |
+
acc = next((d for d in map(_d, root.iter(_q("acceptance_date"))) if d), None)
|
| 997 |
+
pub = next((d for d in map(_d, root.iter(_q("publication_date"))) if d), None)
|
| 998 |
+
return acc, pub
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
def _make_cover(sub_dir: Path, slug: str, *, log) -> dict:
|
| 1002 |
+
"""Run tools/wrap-paper-pdf.py on the PDF just copied to the website tree.
|
| 1003 |
+
Returns {"file", "made_at", "path"} or {"error", "at"}; never raises."""
|
| 1004 |
+
import subprocess
|
| 1005 |
+
import publications
|
| 1006 |
+
sub_id = sub_dir.name
|
| 1007 |
+
out = Path(publications.WEBSITE_REPO) / "public" / "papers" / f"{sub_id}-icsac.pdf"
|
| 1008 |
+
acc, pub = _deposited_dates(sub_dir / "crossref" / "deposit.xml")
|
| 1009 |
+
cmd = [sys.executable, str(COVER_TOOL), "--slug", slug]
|
| 1010 |
+
if acc:
|
| 1011 |
+
cmd += ["--accepted", acc.isoformat()]
|
| 1012 |
+
if pub:
|
| 1013 |
+
cmd += ["--published", pub.isoformat()]
|
| 1014 |
+
started = time.time()
|
| 1015 |
+
try:
|
| 1016 |
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
|
| 1017 |
+
env=dict(os.environ, ICSAC_WEBSITE_REPO=publications.WEBSITE_REPO))
|
| 1018 |
+
ok = r.returncode == 0 and out.exists() and out.stat().st_mtime >= started - 1
|
| 1019 |
+
said = [ln for ln in (r.stderr or "").splitlines() if ln.startswith("cover not made: ")]
|
| 1020 |
+
tail = (r.stderr or "").strip().splitlines()
|
| 1021 |
+
why = (said[-1][len("cover not made: "):] if said else
|
| 1022 |
+
f"the tool exited 0 but did not write {out.name}" if r.returncode == 0 else
|
| 1023 |
+
tail[-1] if tail else f"the tool exited {r.returncode}")
|
| 1024 |
+
except Exception as exc:
|
| 1025 |
+
ok, why = False, f"{type(exc).__name__}: {exc}"
|
| 1026 |
+
if ok:
|
| 1027 |
+
log(f" crossref: cover page made -> /papers/{out.name}")
|
| 1028 |
+
return {"file": f"/papers/{out.name}", "made_at": _now_iso(), "path": str(out)}
|
| 1029 |
+
log(f" crossref: COVER NOT MADE for {sub_id}: {why[:300]}")
|
| 1030 |
+
return {"error": why[:300], "at": _now_iso()}
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def _cover_line(cover: Optional[dict], slug: Optional[str]) -> str:
|
| 1034 |
+
if not cover:
|
| 1035 |
+
return "not recorded"
|
| 1036 |
+
if cover.get("file"):
|
| 1037 |
+
return f"made ({cover['file']})"
|
| 1038 |
+
return (f"NOT MADE ({cover.get('error')}). The page links the bare PDF until it exists: fix, run "
|
| 1039 |
+
f"tools/wrap-paper-pdf.py --slug {slug or '<slug>'}, then commit + push the website")
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dict:
|
| 1043 |
"""Upsert /publications, copy the PDF to the website tree, stage the public
|
| 1044 |
review, push. Mirrors publish_watcher._register_published for Zenodo."""
|
|
|
|
| 1080 |
log(" crossref: publications registry not configured; landing page NOT pushed")
|
| 1081 |
return {}
|
| 1082 |
extra = []
|
| 1083 |
+
cover: dict = {}
|
| 1084 |
if publications.WEBSITE_REPO:
|
| 1085 |
pdf_src = sub_dir / "paper.pdf"
|
| 1086 |
if pdf_src.exists():
|
|
|
|
| 1088 |
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 1089 |
shutil.copyfile(pdf_src, dest)
|
| 1090 |
extra.append(str(dest))
|
| 1091 |
+
# The ICSAC edition beside the bare file, in the same commit. A failed
|
| 1092 |
+
# cover never blocks the page (it links the bare PDF until one exists),
|
| 1093 |
+
# and it is never silent: it reaches the log, state.json and the ping.
|
| 1094 |
+
cover = _make_cover(sub_dir, entry["slug"], log=log)
|
| 1095 |
+
if cover.get("path"):
|
| 1096 |
+
extra.append(cover["path"])
|
| 1097 |
+
else:
|
| 1098 |
+
cover = {"error": f"no paper.pdf in {sub_dir.name}, so no PDF was published", "at": _now_iso()}
|
| 1099 |
+
log(f" crossref: COVER NOT MADE for {sub_id}: {cover['error']}")
|
| 1100 |
review_md, rqc_md = publications.stage_public_review_for_slug(
|
| 1101 |
sub_id, entry["slug"], config.REVIEWS_DIR, approved=True) # (review_key, slug, dir) -- same call as publish_watcher
|
| 1102 |
# The public review record travels in the same commit as the registry entry;
|
|
|
|
| 1134 |
state = json.loads(state_p.read_text()) if state_p.exists() else {}
|
| 1135 |
state["publications_slug"] = entry["slug"]
|
| 1136 |
state["publications_url"] = publications.publications_url(entry["slug"])
|
| 1137 |
+
if cover:
|
| 1138 |
+
state["cover"] = {k: v for k, v in cover.items() if k != "path"}
|
| 1139 |
_write_json_atomic(state_p, state)
|
| 1140 |
log(f" crossref: publications entry pushed -> {state['publications_url']}")
|
| 1141 |
return entry
|
intake/scripts/test_cover_pdf.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Checks for the ICSAC cover edition of a published PDF (2026-10-18): a paper
|
| 3 |
+
without a Zenodo record_id gets wrapped (<sub_id>.pdf -> <sub_id>-icsac.pdf),
|
| 4 |
+
the cover names the journal, volume, article number, dates, licence and the
|
| 5 |
+
page's citation, the author's pages and the bare file are left untouched,
|
| 6 |
+
register --live makes the cover in the same push, and a failed cover is
|
| 7 |
+
reported (log, state.json, ping) without blocking the page.
|
| 8 |
+
Offline: no network, no mail, no pings; every write lands in one temporary
|
| 9 |
+
directory that is removed at the end, pass or fail. Run from the repo root
|
| 10 |
+
with the venv:
|
| 11 |
+
.venv/bin/python intake/scripts/test_cover_pdf.py
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import importlib.util
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import shutil
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import tempfile
|
| 24 |
+
import types
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 28 |
+
sys.path.insert(0, str(ROOT))
|
| 29 |
+
TOOL = ROOT / "tools" / "wrap-paper-pdf.py"
|
| 30 |
+
|
| 31 |
+
failures: list[str] = []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def check(cond: bool, name: str) -> None:
|
| 35 |
+
print((" ok " if cond else " FAIL ") + name)
|
| 36 |
+
if not cond:
|
| 37 |
+
failures.append(name)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def sha(p: Path) -> str:
|
| 41 |
+
return hashlib.sha256(p.read_bytes()).hexdigest()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def flat(text: str) -> str:
|
| 45 |
+
return re.sub(r"\s+", " ", text or "").strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def squeeze(text: str) -> str:
|
| 49 |
+
"""Text extraction splits words at kerned pairs ("T est"): compare without spaces."""
|
| 50 |
+
return re.sub(r"\s+", "", text or "")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
PERSISTENCE_TS = """export const PERSISTENCE_TITLE = 'Persistence';
|
| 54 |
+
export const PERSISTENCE_VOLUMES: ReadonlyArray<{ volume: string; from: string; year: string }> = [
|
| 55 |
+
{ volume: '1', from: '2026-01-01', year: '2026' },
|
| 56 |
+
];
|
| 57 |
+
export function isInstituteDoi(doi: string): boolean {
|
| 58 |
+
return /^10\\.5555\\//i.test(bare(doi));
|
| 59 |
+
}
|
| 60 |
+
"""
|
| 61 |
+
CC_BY = "https://creativecommons.org/licenses/by/4.0/"
|
| 62 |
+
DEPOSIT_XML = """<?xml version="1.0"?>
|
| 63 |
+
<doi_batch xmlns="http://www.crossref.org/schema/5.4.0"><body><journal>
|
| 64 |
+
<journal_issue><publication_date media_type="online"><year>2026</year></publication_date></journal_issue>
|
| 65 |
+
<journal_article><publication_date media_type="online"><month>03</month><day>03</day><year>2026</year></publication_date>
|
| 66 |
+
<acceptance_date><month>03</month><day>02</day><year>2026</year></acceptance_date></journal_article>
|
| 67 |
+
</journal></body></doi_batch>"""
|
| 68 |
+
|
| 69 |
+
tmp = Path(tempfile.mkdtemp(prefix="cover-pdf-"))
|
| 70 |
+
try:
|
| 71 |
+
import weasyprint
|
| 72 |
+
import pypdf
|
| 73 |
+
from PIL import Image
|
| 74 |
+
|
| 75 |
+
def make_site(name: str) -> Path:
|
| 76 |
+
site = tmp / name
|
| 77 |
+
(site / "src" / "data").mkdir(parents=True)
|
| 78 |
+
(site / "src" / "lib").mkdir(parents=True)
|
| 79 |
+
(site / "public" / "papers").mkdir(parents=True)
|
| 80 |
+
(site / "src" / "lib" / "persistence.ts").write_text(PERSISTENCE_TS)
|
| 81 |
+
Image.new("RGBA", (330, 200), (139, 94, 60, 255)).save(site / "public" / "logo_trans_ICSAC_notext.png")
|
| 82 |
+
return site
|
| 83 |
+
|
| 84 |
+
def make_pdf(path: Path, pages: int = 2) -> None:
|
| 85 |
+
body = "".join(f'<p style="page-break-after: always">Manuscript page {i + 1}. '
|
| 86 |
+
f'<a href="https://example.com/{i}">a link</a></p>' for i in range(pages))
|
| 87 |
+
path.write_bytes(weasyprint.HTML(string=f"<html><body>{body}</body></html>").write_pdf())
|
| 88 |
+
|
| 89 |
+
def run_tool(site: Path, *args: str) -> subprocess.CompletedProcess:
|
| 90 |
+
return subprocess.run([sys.executable, str(TOOL), *args], capture_output=True, text=True,
|
| 91 |
+
env=dict(os.environ, ICSAC_WEBSITE_REPO=str(site)), timeout=300)
|
| 92 |
+
|
| 93 |
+
PAPER = {"slug": "a-test-article", "sub_id": "ICSAC-SUB-09999", "title": "A Test Article",
|
| 94 |
+
"authors": ["Ada B. Lovelace", "Alan Turing", "Grace Hopper"], "doi": "10.5555/icsac.2026.999",
|
| 95 |
+
"accepted_date": "2026-03-03", "source": "submission-pdf", "license_url": CC_BY}
|
| 96 |
+
|
| 97 |
+
# ββ 1. a paper without a record_id gets wrapped ββββββββββββββββββββββββββ
|
| 98 |
+
print("1. a paper without a Zenodo record_id")
|
| 99 |
+
site = make_site("site1")
|
| 100 |
+
(site / "src" / "data" / "accepted.json").write_text(json.dumps([PAPER]))
|
| 101 |
+
bare = site / "public" / "papers" / "ICSAC-SUB-09999.pdf"
|
| 102 |
+
make_pdf(bare)
|
| 103 |
+
before = sha(bare)
|
| 104 |
+
r = run_tool(site, "--slug", "a-test-article", "--accepted", "2026-03-02", "--published", "2026-03-03")
|
| 105 |
+
out = site / "public" / "papers" / "ICSAC-SUB-09999-icsac.pdf"
|
| 106 |
+
check(r.returncode == 0 and out.exists(), "exit 0 and <sub_id>-icsac.pdf written beside the bare file")
|
| 107 |
+
check(sha(bare) == before, "the bare file is byte-for-byte unchanged")
|
| 108 |
+
got, src = pypdf.PdfReader(str(out)), pypdf.PdfReader(str(bare))
|
| 109 |
+
check(len(got.pages) == len(src.pages) + 1, "one page added, no more")
|
| 110 |
+
p1 = flat(got.pages[0].extract_text())
|
| 111 |
+
for want, what in (("Persistence", "names the journal"),
|
| 112 |
+
("VOLUME 1 (2026)", "names the volume and year"),
|
| 113 |
+
("ARTICLE 999", "names the article number from the Institute DOI"),
|
| 114 |
+
("10.5555/icsac.2026.999", "carries the DOI"),
|
| 115 |
+
("03/02/2026", "carries the acceptance date"),
|
| 116 |
+
("Published online", "labels the publication date"),
|
| 117 |
+
("03/03/2026", "carries the publication date"),
|
| 118 |
+
("CC BY 4.0", "carries the licence"),
|
| 119 |
+
("https://icsacinstitute.org/publications/a-test-article", "carries the editorial record URL"),
|
| 120 |
+
("Reviewed by the ICSAC panel", "keeps the open-review line"),
|
| 121 |
+
("does not modify the author's manuscript", "keeps the does-not-modify paragraph")):
|
| 122 |
+
check(want.lower() in p1.lower(), f"cover {what}") # labels print in small caps (CSS uppercase)
|
| 123 |
+
check("peer review" not in p1.lower() and "peer-review" not in p1.lower(), "cover never says peer review")
|
| 124 |
+
check(squeeze("Lovelace, A. B., Turing, A., & Hopper, G. (2026). A Test Article. Persistence, 1, Article 999. "
|
| 125 |
+
"https://doi.org/10.5555/icsac.2026.999") in squeeze(p1), "cite-as line in the page's journal format")
|
| 126 |
+
same = all(got.pages[i + 1].get_contents().get_data() == src.pages[i].get_contents().get_data()
|
| 127 |
+
for i in range(len(src.pages)))
|
| 128 |
+
check(same, "pages 2..n carry the author's content streams unchanged")
|
| 129 |
+
check(all(len(got.pages[i + 1].get("/Annots") or []) == len(src.pages[i].get("/Annots") or [])
|
| 130 |
+
for i in range(len(src.pages))), "the author's link annotations are kept")
|
| 131 |
+
hv = lambda rd: tuple(map(int, re.match(r"%PDF-(\d+)\.(\d+)", rd.pdf_header).groups()))
|
| 132 |
+
check(hv(got) >= hv(src), f"PDF header {got.pdf_header} is not older than the source's {src.pdf_header}")
|
| 133 |
+
imgs = [(x.image.width, x.image.height) for x in got.pages[0].images]
|
| 134 |
+
check(imgs and all(w <= 400 and h <= 250 for w, h in imgs), f"the cover carries only the small logo {imgs}")
|
| 135 |
+
|
| 136 |
+
# ββ 2. a Zenodo-route paper: first publication before acceptance βββββββββ
|
| 137 |
+
print("2. a Zenodo-route paper")
|
| 138 |
+
z = {"slug": "a-zenodo-paper", "record_id": "12345", "title": "A Zenodo Paper", "authors": ["Test Author"],
|
| 139 |
+
"doi": "10.5281/zenodo.12345", "accepted_date": "2026-05-04", "source": "zenodo-community",
|
| 140 |
+
"license_url": CC_BY}
|
| 141 |
+
(site / "src" / "data" / "accepted.json").write_text(json.dumps([PAPER, z]))
|
| 142 |
+
make_pdf(site / "public" / "papers" / "12345.pdf", pages=1)
|
| 143 |
+
r = run_tool(site, "--slug", "a-zenodo-paper", "--published", "2026-02-02")
|
| 144 |
+
p1 = flat(pypdf.PdfReader(str(site / "public" / "papers" / "12345-icsac.pdf")).pages[0].extract_text())
|
| 145 |
+
check(r.returncode == 0, "exit 0")
|
| 146 |
+
check("ARTICLE" not in p1.upper().split("VOLUME 1 (2026)")[1][:20], "no article number for a Zenodo DOI")
|
| 147 |
+
check(p1.upper().find("FIRST PUBLISHED") < p1.upper().find("ACCEPTED") and "02/02/2026, on Zenodo" in p1,
|
| 148 |
+
"dates in the order they happened, the first publication named as Zenodo's")
|
| 149 |
+
check(squeeze("Author, T. (2026). A Zenodo Paper. Zenodo. https://doi.org/10.5281/zenodo.12345. "
|
| 150 |
+
"Reviewed by the ICSAC panel.") in squeeze(p1), "cite-as line in the page's Zenodo format")
|
| 151 |
+
|
| 152 |
+
# ββ 3. failures are reported, never silent, and write nothing ββββββββββββ
|
| 153 |
+
print("3. failures")
|
| 154 |
+
bad = dict(PAPER, slug="no-licence", sub_id="ICSAC-SUB-09998", license_url="https://example.com/mine")
|
| 155 |
+
(site / "src" / "data" / "accepted.json").write_text(json.dumps([PAPER, z, bad]))
|
| 156 |
+
make_pdf(site / "public" / "papers" / "ICSAC-SUB-09998.pdf", pages=1)
|
| 157 |
+
b_before = sha(site / "public" / "papers" / "ICSAC-SUB-09998.pdf")
|
| 158 |
+
r = run_tool(site, "--slug", "no-licence", "--accepted", "2026-03-02", "--published", "2026-03-03")
|
| 159 |
+
check(r.returncode == 1 and "cover not made" in r.stderr and "licence" in r.stderr,
|
| 160 |
+
"an unknown licence: exit 1, 'cover not made' with the reason")
|
| 161 |
+
check(not (site / "public" / "papers" / "ICSAC-SUB-09998-icsac.pdf").exists(), "nothing written for it")
|
| 162 |
+
check(sha(site / "public" / "papers" / "ICSAC-SUB-09998.pdf") == b_before, "its bare file untouched")
|
| 163 |
+
missing = dict(PAPER, slug="no-pdf", sub_id="ICSAC-SUB-09997")
|
| 164 |
+
(site / "src" / "data" / "accepted.json").write_text(json.dumps([missing]))
|
| 165 |
+
r = run_tool(site, "--slug", "no-pdf", "--accepted", "2026-03-02", "--published", "2026-03-03")
|
| 166 |
+
check(r.returncode == 1 and "source PDF missing" in r.stderr, "a missing source PDF: exit 1 with the reason")
|
| 167 |
+
nokey = {k: v for k, v in PAPER.items() if k != "sub_id"} | {"slug": "doi-only"}
|
| 168 |
+
(site / "src" / "data" / "accepted.json").write_text(json.dumps([missing, nokey]))
|
| 169 |
+
r = run_tool(site, "--all")
|
| 170 |
+
check(r.returncode == 1 and "nothing to cover" in r.stdout and "doi-only" in r.stdout,
|
| 171 |
+
"--all names a paper with no PDF on the site instead of skipping it quietly")
|
| 172 |
+
check("no-pdf" in r.stderr and "1 not made" in r.stdout, "--all reports the failure and counts it")
|
| 173 |
+
|
| 174 |
+
# ββ 4. the citation port matches the page's rules (cite.ts) ββββββββββββββ
|
| 175 |
+
print("4. citation port")
|
| 176 |
+
spec = importlib.util.spec_from_file_location("wrap_paper_pdf", TOOL)
|
| 177 |
+
wp = importlib.util.module_from_spec(spec); spec.loader.exec_module(wp)
|
| 178 |
+
vols = [{"volume": "1", "from": "2026-01-01", "year": "2026"}, {"volume": "2", "from": "2027-06-01", "year": "2027"}]
|
| 179 |
+
check(wp.format_author_apa("Grace B. Hopper") == "Hopper, G. B.", "APA: initials, kept initial")
|
| 180 |
+
check(wp.format_author_apa("Hopper, Grace") == "Hopper, Grace", "APA: 'Last, First' kept as written")
|
| 181 |
+
check(wp.format_author_apa("Plato") == "Plato", "APA: a single name kept")
|
| 182 |
+
check(wp.authors_apa_list([]) == "Anonymous", "no authors -> Anonymous")
|
| 183 |
+
check(wp.authors_apa_list(["A B", "C D"]) == "B, A. & D, C.", "two authors joined with &")
|
| 184 |
+
check(wp.volume_for("2027-07-01", vols)["volume"] == "2", "volume from the table by acceptance date")
|
| 185 |
+
check(wp.article_number_for("doi:10.5555/icsac.2026.012", "10.5555") == "012", "article number from the DOI sequence")
|
| 186 |
+
check(wp.article_number_for("10.5281/zenodo.1", "10.5555") is None, "no article number for a Zenodo DOI")
|
| 187 |
+
real_ts = (Path(os.environ.get("ICSAC_WEBSITE_REPO", "")) / "src" / "lib" / "persistence.ts")
|
| 188 |
+
if real_ts.exists(): # the operator checkout: the prefix the live page uses is read, not assumed
|
| 189 |
+
check(re.fullmatch(r"10\.\d{4,9}", wp.Site(real_ts.parents[2]).journal()["prefix"] or "") is not None,
|
| 190 |
+
"the website's persistence.ts yields a DOI prefix")
|
| 191 |
+
site_j = wp.Site(site).journal()
|
| 192 |
+
check(site_j["prefix"] == "10.5555" and site_j["title"] == "Persistence", "the fixture's prefix and title are read")
|
| 193 |
+
|
| 194 |
+
# ββ 5. register --live makes the cover in the same push ββββββββββββββββββ
|
| 195 |
+
print("5. register --live")
|
| 196 |
+
import publications as pub
|
| 197 |
+
import crossref_deposit as cd
|
| 198 |
+
from intake import author_approval
|
| 199 |
+
sys.modules["stats"] = types.SimpleNamespace(
|
| 200 |
+
compute_stats=lambda *a, **k: (_ for _ in ()).throw(RuntimeError("stats stubbed")))
|
| 201 |
+
pushed: list[list[str]] = []
|
| 202 |
+
pings: list[str] = []
|
| 203 |
+
pub.READY_DIR = str(tmp / "ready-to-publish")
|
| 204 |
+
pub.stage_public_review_for_slug = lambda *a, **k: (None, None)
|
| 205 |
+
pub.commit_and_push = lambda *a, **k: pushed.append(list(k.get("extra_paths") or []))
|
| 206 |
+
cd.stage = lambda *a, **k: None
|
| 207 |
+
cd.validate_xml = lambda b: None
|
| 208 |
+
cd.deposit = lambda *a, **k: "batch-1"
|
| 209 |
+
cd.poll_result = lambda *a, **k: {"status": "completed", "success": 1, "failure": 0}
|
| 210 |
+
cd._ping = pings.append
|
| 211 |
+
cd._publish_zenodo_archive = lambda *a, **k: None
|
| 212 |
+
cd._notify_author = lambda *a, **k: True
|
| 213 |
+
author_approval.gate = lambda *a, **k: (True, "approved (stub)")
|
| 214 |
+
|
| 215 |
+
def make_sub(name: str, site: Path, with_pdf: bool = True) -> Path:
|
| 216 |
+
sub = tmp / name
|
| 217 |
+
(sub / "crossref").mkdir(parents=True)
|
| 218 |
+
(sub / "submission.json").write_text(json.dumps({
|
| 219 |
+
"sub_id": name, "title": f"Article {name}", "abstract": "A", "license": "cc-by-4.0",
|
| 220 |
+
"creators": [{"name": "Lovelace, Ada"}], "form": {"name": "Ada Lovelace"}}))
|
| 221 |
+
(sub / "state.json").write_text(json.dumps({"decision": "accept", "state": "completed"}))
|
| 222 |
+
(sub / "crossref" / "deposit.xml").write_text(DEPOSIT_XML)
|
| 223 |
+
(sub / "crossref" / "staged.json").write_text(json.dumps({"doi": "10.5555/icsac.2026.998", "registered": False}))
|
| 224 |
+
if with_pdf:
|
| 225 |
+
make_pdf(sub / "paper.pdf", pages=2)
|
| 226 |
+
pub.WEBSITE_REPO = str(site)
|
| 227 |
+
pub.REGISTRY_PATH = str(site / "src" / "data" / "accepted.json")
|
| 228 |
+
return sub
|
| 229 |
+
|
| 230 |
+
check(cd._deposited_dates(Path(tmp / "nope.xml")) == (None, None), "no deposit file -> no dates, no crash")
|
| 231 |
+
site5 = make_site("site5"); (site5 / "src" / "data" / "accepted.json").write_text("[]")
|
| 232 |
+
sub = make_sub("ICSAC-SUB-09990", site5)
|
| 233 |
+
acc, pubd = cd._deposited_dates(sub / "crossref" / "deposit.xml")
|
| 234 |
+
check((str(acc), str(pubd)) == ("2026-03-02", "2026-03-03"), "the deposited dates are read (the article's, not the issue's year)")
|
| 235 |
+
cd.register(sub, live=True, log=lambda m: None)
|
| 236 |
+
cov = site5 / "public" / "papers" / "ICSAC-SUB-09990-icsac.pdf"
|
| 237 |
+
bare5 = site5 / "public" / "papers" / "ICSAC-SUB-09990.pdf"
|
| 238 |
+
check(cov.exists(), "register --live wrote <sub_id>-icsac.pdf")
|
| 239 |
+
check(sha(bare5) == sha(sub / "paper.pdf"), "the bare copy is the author's paper.pdf, untouched")
|
| 240 |
+
check(pushed and str(cov) in pushed[-1] and str(bare5) in pushed[-1], "both files ride the same commit")
|
| 241 |
+
st = json.loads((sub / "state.json").read_text())
|
| 242 |
+
check(st.get("cover", {}).get("file") == "/papers/ICSAC-SUB-09990-icsac.pdf", "state.json records the cover")
|
| 243 |
+
check(pings and "Cover page: made (/papers/ICSAC-SUB-09990-icsac.pdf)" in pings[-1], "the ping says the cover was made")
|
| 244 |
+
p1 = flat(pypdf.PdfReader(str(cov)).pages[0].extract_text())
|
| 245 |
+
check("03/02/2026" in p1 and "03/03/2026" in p1, "the cover carries the deposited dates")
|
| 246 |
+
|
| 247 |
+
# ββ 6. a failed cover is reported and does not block the page ββββββββββββ
|
| 248 |
+
print("6. a failed cover")
|
| 249 |
+
failing = tmp / "failing-tool.py"
|
| 250 |
+
failing.write_text("import sys\nprint('cover not made: boom (stub)', file=sys.stderr)\nsys.exit(1)\n")
|
| 251 |
+
real_tool = cd.COVER_TOOL
|
| 252 |
+
cd.COVER_TOOL = failing
|
| 253 |
+
try:
|
| 254 |
+
site6 = make_site("site6"); (site6 / "src" / "data" / "accepted.json").write_text("[]")
|
| 255 |
+
sub = make_sub("ICSAC-SUB-09991", site6)
|
| 256 |
+
logs: list[str] = []
|
| 257 |
+
out = cd.register(sub, live=True, log=logs.append)
|
| 258 |
+
ck = json.loads((sub / "crossref" / "staged.json").read_text())["checkpoints"]
|
| 259 |
+
st = json.loads((sub / "state.json").read_text())
|
| 260 |
+
check(bool(ck.get("publications_pushed_at")) and out.get("author_draft") == "drafted",
|
| 261 |
+
"the page is still published and the later steps run")
|
| 262 |
+
check(st.get("cover", {}).get("error") == "boom (stub)", "state.json records why")
|
| 263 |
+
check(any("COVER NOT MADE" in m and "boom" in m for m in logs), "the log says COVER NOT MADE with the reason")
|
| 264 |
+
check("Cover page: NOT MADE (boom (stub))" in pings[-1] and "wrap-paper-pdf.py --slug" in pings[-1],
|
| 265 |
+
"the ping says NOT MADE and how to make it")
|
| 266 |
+
check(not (site6 / "public" / "papers" / "ICSAC-SUB-09991-icsac.pdf").exists()
|
| 267 |
+
and all("icsac.pdf" not in p for p in pushed[-1]), "no cover file written or committed")
|
| 268 |
+
finally:
|
| 269 |
+
cd.COVER_TOOL = real_tool
|
| 270 |
+
site7 = make_site("site7"); (site7 / "src" / "data" / "accepted.json").write_text("[]")
|
| 271 |
+
sub = make_sub("ICSAC-SUB-09992", site7, with_pdf=False)
|
| 272 |
+
cd.register(sub, live=True, log=lambda m: None)
|
| 273 |
+
check("Cover page: NOT MADE (no paper.pdf" in pings[-1], "no paper.pdf: reported, not skipped")
|
| 274 |
+
finally:
|
| 275 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 276 |
+
|
| 277 |
+
print()
|
| 278 |
+
if failures:
|
| 279 |
+
print(f"FAILED: {len(failures)}")
|
| 280 |
+
for f in failures:
|
| 281 |
+
print(" - " + f)
|
| 282 |
+
sys.exit(1)
|
| 283 |
+
print("ALL GREEN")
|
intake/scripts/test_sponsor_logo_hold.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Checks that a Stripe sponsor-logo upload is held for the curator (nothing
|
| 3 |
+
publishes without the curator's approval) and that --apply
|
| 4 |
+
publishes it only on the typed word "publish".
|
| 5 |
+
Offline: Stripe, Telegram and the curator ping are stubbed; the website repo
|
| 6 |
+
and its remote are temporary git repositories, removed at the end, pass or fail.
|
| 7 |
+
Run from the repo root:
|
| 8 |
+
.venv/bin/python intake/scripts/test_sponsor_logo_hold.py
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import builtins
|
| 14 |
+
import inspect
|
| 15 |
+
import json
|
| 16 |
+
import shutil
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
import tempfile
|
| 20 |
+
import types
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 24 |
+
sys.path.insert(0, str(ROOT))
|
| 25 |
+
|
| 26 |
+
from fastapi import HTTPException # noqa: E402
|
| 27 |
+
|
| 28 |
+
failures: list[str] = []
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def check(cond: bool, name: str) -> None:
|
| 32 |
+
print((" ok " if cond else " FAIL ") + name)
|
| 33 |
+
if not cond:
|
| 34 |
+
failures.append(name)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def git(repo: Path, *args: str) -> str:
|
| 38 |
+
return subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t", *args],
|
| 39 |
+
cwd=repo, capture_output=True, text=True, check=True).stdout
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class FakeUpload:
|
| 43 |
+
def __init__(self, data: bytes):
|
| 44 |
+
self.data = data
|
| 45 |
+
|
| 46 |
+
async def read(self, n: int = -1) -> bytes:
|
| 47 |
+
return self.data[:n] if n and n > 0 else self.data
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
| 51 |
+
SVG = b'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="#000"/></svg>'
|
| 52 |
+
BAD_SVG = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
| 53 |
+
SESSION_ID = "cs_test_abc123"
|
| 54 |
+
|
| 55 |
+
tmp = Path(tempfile.mkdtemp(prefix="sponsor-logo-hold-"))
|
| 56 |
+
real_stdin, real_input = sys.stdin, builtins.input
|
| 57 |
+
try:
|
| 58 |
+
from intake import sponsor_logo as sl
|
| 59 |
+
import notify
|
| 60 |
+
|
| 61 |
+
remote = tmp / "remote.git"
|
| 62 |
+
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(remote)], check=True)
|
| 63 |
+
site = tmp / "site"
|
| 64 |
+
subprocess.run(["git", "clone", "-q", str(remote), str(site)], check=True, capture_output=True)
|
| 65 |
+
git(site, "checkout", "-q", "-b", "main")
|
| 66 |
+
(site / "src" / "data").mkdir(parents=True)
|
| 67 |
+
(site / "public" / "supporter-logos").mkdir(parents=True)
|
| 68 |
+
(site / "public" / "supporter-logos" / ".keep").write_text("")
|
| 69 |
+
yaml_path = site / "src" / "data" / "supporters.yaml"
|
| 70 |
+
yaml_path.write_text(sl._render_supporters({"supporters": [
|
| 71 |
+
{"display_name": "Example Sponsor Co", "tier": "sponsor", "joined": "2026-10-01", "status": "active",
|
| 72 |
+
"stripe_sub": "sub_example", "website_url": "https://example.com", "logo_pending": True}]}))
|
| 73 |
+
git(site, "add", "-A")
|
| 74 |
+
git(site, "commit", "-q", "-m", "seed")
|
| 75 |
+
git(site, "push", "-q", "-u", "origin", "main")
|
| 76 |
+
|
| 77 |
+
sl.WEBSITE_REPO = site
|
| 78 |
+
sl.LOGO_DIR = site / "public" / "supporter-logos"
|
| 79 |
+
sl.SUPPORTERS_YAML = yaml_path
|
| 80 |
+
sl.READY_DIR = tmp / "ready"
|
| 81 |
+
sl._retrieve_checkout_session = lambda sid: {
|
| 82 |
+
"mode": "subscription", "payment_status": "paid",
|
| 83 |
+
"subscription": {"id": "sub_example", "status": "active",
|
| 84 |
+
"items": {"data": [{"price": {"product": sl.SPONSOR_PRODUCT_ID}, "current_period_end": 4_102_444_800}]}},
|
| 85 |
+
"custom_fields": [{"key": "display_name", "text": {"value": "Example Sponsor Co"}},
|
| 86 |
+
{"key": "website_url", "text": {"value": "example.com"}}]}
|
| 87 |
+
pings: list[str] = []
|
| 88 |
+
notify.send_to_curator = lambda message, **k: (pings.append(message), True)[1]
|
| 89 |
+
images: list[tuple[str, str]] = []
|
| 90 |
+
sl._send_logo_to_telegram = lambda image, caption: (images.append((image.name, caption)), True)[1]
|
| 91 |
+
seed_head = git(site, "rev-parse", "HEAD").strip()
|
| 92 |
+
seed_yaml = yaml_path.read_text()
|
| 93 |
+
|
| 94 |
+
def upload(data: bytes) -> dict:
|
| 95 |
+
return asyncio.run(sl.handle_sponsor_logo(None, SESSION_ID, FakeUpload(data), b""))
|
| 96 |
+
|
| 97 |
+
print("1. an upload is held, not published")
|
| 98 |
+
out = upload(PNG)
|
| 99 |
+
held_json = sl.READY_DIR / f"{sl.STAGE_PREFIX}{__import__('hashlib').sha256(SESSION_ID.encode()).hexdigest()[:8]}.json"
|
| 100 |
+
check(out.get("ok") is True and out.get("held") is True, "the sponsor gets a success answer marked held")
|
| 101 |
+
check(git(site, "status", "--porcelain") == "", "the website repo is untouched")
|
| 102 |
+
check(yaml_path.read_text() == seed_yaml, "supporters.yaml is unchanged")
|
| 103 |
+
check(sorted(p.name for p in sl.LOGO_DIR.iterdir()) == [".keep"], "no logo file reaches the site")
|
| 104 |
+
check(git(site, "rev-parse", "HEAD").strip() == seed_head, "no commit was made")
|
| 105 |
+
check(held_json.exists() and held_json.with_suffix(".png").exists(), "the logo and its listing wait in ready-to-publish")
|
| 106 |
+
rec = json.loads(held_json.read_text())
|
| 107 |
+
check(rec["display_name"] == "Example Sponsor Co" and rec["website_url"] == "https://example.com"
|
| 108 |
+
and rec["stripe_sub"] == "sub_example", "the held listing carries the Stripe-verified details")
|
| 109 |
+
check(len(pings) == 1 and "Example Sponsor Co" not in pings[0] and "--apply" in pings[0],
|
| 110 |
+
"one curator ping, with no sponsor name, naming the apply command")
|
| 111 |
+
check(len(images) == 1 and images[0][0].endswith(".png") and "Example Sponsor Co" in images[0][1],
|
| 112 |
+
"the image itself goes to the curator's Telegram")
|
| 113 |
+
|
| 114 |
+
print("2. a re-upload replaces the held one")
|
| 115 |
+
upload(SVG)
|
| 116 |
+
check(held_json.with_suffix(".svg").exists() and not held_json.with_suffix(".png").exists(),
|
| 117 |
+
"the SVG replaced the held PNG")
|
| 118 |
+
check(json.loads(held_json.read_text())["logo_ext"] == "svg", "the held listing points at the new file")
|
| 119 |
+
|
| 120 |
+
print("3. an active SVG is refused before anything is held")
|
| 121 |
+
try:
|
| 122 |
+
upload(BAD_SVG)
|
| 123 |
+
check(False, "a scripted SVG is refused")
|
| 124 |
+
except HTTPException as e:
|
| 125 |
+
check(e.status_code == 415, "a scripted SVG is refused (415)")
|
| 126 |
+
check(json.loads(held_json.read_text())["logo_ext"] == "svg", "the held upload is left as it was")
|
| 127 |
+
|
| 128 |
+
print("4. apply refuses without a terminal or the typed word")
|
| 129 |
+
sys.stdin = types.SimpleNamespace(isatty=lambda: False)
|
| 130 |
+
check(sl.main(["--apply", str(held_json)]) == 1, "no terminal: refused")
|
| 131 |
+
sys.stdin = types.SimpleNamespace(isatty=lambda: True)
|
| 132 |
+
builtins.input = lambda prompt="": "yes"
|
| 133 |
+
check(sl.main(["--apply", str(held_json)]) == 1, "a word other than publish: refused")
|
| 134 |
+
check(git(site, "status", "--porcelain") == "" and yaml_path.read_text() == seed_yaml, "still nothing written")
|
| 135 |
+
|
| 136 |
+
print("5. a held image that changed after the upload is refused")
|
| 137 |
+
svg_path = held_json.with_suffix(".svg")
|
| 138 |
+
svg_path.write_bytes(SVG.replace(b"#000", b"#fff"))
|
| 139 |
+
builtins.input = lambda prompt="": "publish"
|
| 140 |
+
check(sl.main(["--apply", str(held_json)]) == 1, "sha mismatch: refused")
|
| 141 |
+
svg_path.write_bytes(SVG)
|
| 142 |
+
|
| 143 |
+
print("6. publish writes the logo and the listing, commits as ICSAC, pushes")
|
| 144 |
+
check(sl.main(["--apply", str(held_json)]) == 0, "apply returns 0")
|
| 145 |
+
data = __import__("yaml").safe_load(yaml_path.read_text())
|
| 146 |
+
entry = data["supporters"][0]
|
| 147 |
+
logo_name = held_json.stem.removeprefix(sl.STAGE_PREFIX) + ".svg"
|
| 148 |
+
check(entry.get("logo_path") == f"/supporter-logos/{logo_name}" and "logo_pending" not in entry,
|
| 149 |
+
"the record now points at the logo; logo_pending cleared")
|
| 150 |
+
check((sl.LOGO_DIR / logo_name).read_bytes() == SVG, "the logo file is on the site")
|
| 151 |
+
check(git(site, "log", "-1", "--format=%an <%ae>").strip() == "ICSAC <info@icsacinstitute.org>", "committed as ICSAC")
|
| 152 |
+
check(git(remote, "rev-parse", "main").strip() == git(site, "rev-parse", "HEAD").strip(), "pushed to the remote")
|
| 153 |
+
check(not held_json.exists() and held_json.with_name(held_json.name + ".applied").exists()
|
| 154 |
+
and svg_path.with_name(svg_path.name + ".applied").exists(), "the held files are marked applied")
|
| 155 |
+
|
| 156 |
+
print("7. the upload path itself can never publish")
|
| 157 |
+
src = inspect.getsource(sl.handle_sponsor_logo)
|
| 158 |
+
check(not any(w in src for w in ("_git_output", "_write_supporters", "push")), "handle_sponsor_logo has no git or YAML write")
|
| 159 |
+
finally:
|
| 160 |
+
sys.stdin, builtins.input = real_stdin, real_input
|
| 161 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 162 |
+
|
| 163 |
+
print(f"\n{'PASS' if not failures else 'FAILED'}: {len(failures)} failure(s)")
|
| 164 |
+
sys.exit(0 if not failures else 1)
|
intake/sponsor_logo.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
| 3 |
Lives next to intake_server.py and gets wired in via a single import +
|
| 4 |
route registration there. Kept in its own module so the submission
|
| 5 |
intake stays focused; this module owns the website-repo side effects
|
| 6 |
-
(file write, supporters.yaml mutation, git commit + push)
|
|
|
|
| 7 |
|
| 8 |
The Stripe Checkout Session ID is the auth token: anyone with a valid
|
| 9 |
paid Sponsor session can upload a logo for that session. Stripe is
|
|
@@ -19,19 +20,32 @@ Filename for the uploaded logo: first 8 chars of sha256(session_id) +
|
|
| 19 |
canonical extension by mime sniff (PNG/JPEG/SVG/WebP). The session-id
|
| 20 |
hash is opaque, collision-free in practice, and stable for re-uploads.
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
| 30 |
|
|
|
|
|
|
|
| 31 |
import hashlib
|
|
|
|
| 32 |
import os
|
| 33 |
import re
|
| 34 |
import subprocess
|
|
|
|
| 35 |
import time
|
| 36 |
from datetime import datetime, timezone
|
| 37 |
from pathlib import Path
|
|
@@ -50,6 +64,9 @@ SPONSOR_PRODUCT_ID = "prod_UXiXkUln7XbrQz"
|
|
| 50 |
WEBSITE_REPO = Path.home() / "Desktop" / "icsac" / "icsacinstitute.org"
|
| 51 |
LOGO_DIR = WEBSITE_REPO / "public" / "supporter-logos"
|
| 52 |
SUPPORTERS_YAML = WEBSITE_REPO / "src" / "data" / "supporters.yaml"
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
# Multipart upload cap. CF Pages edge already enforces ~5 MB; this is
|
| 55 |
# the belt-and-suspenders check after the body parse.
|
|
@@ -227,18 +244,21 @@ def _load_supporters() -> dict[str, Any]:
|
|
| 227 |
return loaded
|
| 228 |
|
| 229 |
|
| 230 |
-
def
|
| 231 |
-
"""Atomic write β temp file + rename so a crash mid-write can't corrupt."""
|
| 232 |
-
SUPPORTERS_YAML.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
-
tmp = SUPPORTERS_YAML.with_suffix(SUPPORTERS_YAML.suffix + ".tmp")
|
| 234 |
header = (
|
| 235 |
"# ICSAC Supporters Registry\n"
|
| 236 |
"# Written by sponsor-logo intake + Stripe poll job.\n"
|
| 237 |
"# Schema: see commit history of this file.\n\n"
|
| 238 |
)
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
with tmp.open("w", encoding="utf-8") as f:
|
| 241 |
-
f.write(
|
| 242 |
tmp.replace(SUPPORTERS_YAML)
|
| 243 |
|
| 244 |
|
|
@@ -281,43 +301,161 @@ def _upsert_sponsor(
|
|
| 281 |
existing["stripe_sub"] = stripe_sub
|
| 282 |
|
| 283 |
|
| 284 |
-
def
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
proc = subprocess.run(
|
| 288 |
-
["git", *args],
|
| 289 |
-
cwd=WEBSITE_REPO,
|
| 290 |
-
capture_output=True,
|
| 291 |
-
text=True,
|
| 292 |
-
check=False,
|
| 293 |
-
timeout=60,
|
| 294 |
-
)
|
| 295 |
if proc.returncode != 0:
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
)
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
def prefill_for_session(session_id: str) -> dict[str, Any]:
|
|
@@ -401,44 +539,35 @@ async def handle_sponsor_logo(
|
|
| 401 |
|
| 402 |
# Filename: opaque 8-char session-id hash. Stable for re-uploads.
|
| 403 |
session_hash = hashlib.sha256(session_id.encode()).hexdigest()[:8]
|
| 404 |
-
out_name = f"{session_hash}.{ext}"
|
| 405 |
-
out_path = LOGO_DIR / out_name
|
| 406 |
-
LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
| 407 |
-
|
| 408 |
-
# Write atomically β temp file + rename, so a partial write can't
|
| 409 |
-
# leave a half-bytes logo on disk.
|
| 410 |
-
tmp_path = out_path.with_suffix(out_path.suffix + ".tmp")
|
| 411 |
-
tmp_path.write_bytes(chunk_bytes)
|
| 412 |
-
tmp_path.replace(out_path)
|
| 413 |
-
|
| 414 |
-
# If this session previously uploaded under a different extension
|
| 415 |
-
# (e.g. PNG then later SVG), purge the stale variants so git diff
|
| 416 |
-
# doesn't accumulate orphans.
|
| 417 |
-
for other_ext in ALLOWED_MIME_EXT.values():
|
| 418 |
-
if other_ext == ext:
|
| 419 |
-
continue
|
| 420 |
-
stale = LOGO_DIR / f"{session_hash}.{other_ext}"
|
| 421 |
-
if stale.exists():
|
| 422 |
-
stale.unlink()
|
| 423 |
-
|
| 424 |
-
logo_path_rel = f"public/supporter-logos/{out_name}"
|
| 425 |
-
|
| 426 |
-
supporters = _load_supporters()
|
| 427 |
-
_upsert_sponsor(
|
| 428 |
-
supporters,
|
| 429 |
-
stripe_sub=info["stripe_sub"],
|
| 430 |
-
display_name=display_name,
|
| 431 |
-
website_url=website_url,
|
| 432 |
-
logo_path="/" + logo_path_rel.removeprefix("public/"),
|
| 433 |
-
valid_through=info["valid_through"],
|
| 434 |
-
)
|
| 435 |
-
_write_supporters(supporters)
|
| 436 |
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
return {
|
| 440 |
"ok": True,
|
|
|
|
| 441 |
"display_name": display_name,
|
| 442 |
-
"
|
| 443 |
-
"committed_at": int(time.time()),
|
| 444 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Lives next to intake_server.py and gets wired in via a single import +
|
| 4 |
route registration there. Kept in its own module so the submission
|
| 5 |
intake stays focused; this module owns the website-repo side effects
|
| 6 |
+
(file write, supporters.yaml mutation, git commit + push), which run only
|
| 7 |
+
when the curator publishes a held upload.
|
| 8 |
|
| 9 |
The Stripe Checkout Session ID is the auth token: anyone with a valid
|
| 10 |
paid Sponsor session can upload a logo for that session. Stripe is
|
|
|
|
| 20 |
canonical extension by mime sniff (PNG/JPEG/SVG/WebP). The session-id
|
| 21 |
hash is opaque, collision-free in practice, and stable for re-uploads.
|
| 22 |
|
| 23 |
+
An upload publishes nothing: nothing reaches the site without the curator's
|
| 24 |
+
approval. Side effects per successful upload:
|
| 25 |
+
1. The logo and the proposed listing are held in the ready-to-publish
|
| 26 |
+
directory as sponsor-logo-<8hex>.<ext> + sponsor-logo-<8hex>.json; a
|
| 27 |
+
re-upload for the same session replaces the held one.
|
| 28 |
+
2. The curator gets one ping (no sponsor name, since it also goes to the
|
| 29 |
+
ntfy backup) and the image itself on Telegram, so it can be seen first.
|
| 30 |
+
|
| 31 |
+
Publishing, from the repo root:
|
| 32 |
+
.venv/bin/python -m intake.sponsor_logo --apply <held .json>
|
| 33 |
+
shows the supporters.yaml diff, asks for the typed word "publish", then
|
| 34 |
+
writes public/supporter-logos/<8hex>.<ext> and the record (keyed by
|
| 35 |
+
stripe_sub; logo_pending cleared, logo_path, display_name and website_url
|
| 36 |
+
set), commits and pushes from the website repo.
|
| 37 |
"""
|
| 38 |
|
| 39 |
from __future__ import annotations
|
| 40 |
|
| 41 |
+
import argparse
|
| 42 |
+
import difflib
|
| 43 |
import hashlib
|
| 44 |
+
import json
|
| 45 |
import os
|
| 46 |
import re
|
| 47 |
import subprocess
|
| 48 |
+
import sys
|
| 49 |
import time
|
| 50 |
from datetime import datetime, timezone
|
| 51 |
from pathlib import Path
|
|
|
|
| 64 |
WEBSITE_REPO = Path.home() / "Desktop" / "icsac" / "icsacinstitute.org"
|
| 65 |
LOGO_DIR = WEBSITE_REPO / "public" / "supporter-logos"
|
| 66 |
SUPPORTERS_YAML = WEBSITE_REPO / "src" / "data" / "supporters.yaml"
|
| 67 |
+
# Held uploads wait for the curator here (the directory publications.py holds entries in).
|
| 68 |
+
READY_DIR = Path.home() / "icsac-submissions" / "ready-to-publish"
|
| 69 |
+
STAGE_PREFIX = "sponsor-logo-"
|
| 70 |
|
| 71 |
# Multipart upload cap. CF Pages edge already enforces ~5 MB; this is
|
| 72 |
# the belt-and-suspenders check after the body parse.
|
|
|
|
| 244 |
return loaded
|
| 245 |
|
| 246 |
|
| 247 |
+
def _render_supporters(data: dict[str, Any]) -> str:
|
|
|
|
|
|
|
|
|
|
| 248 |
header = (
|
| 249 |
"# ICSAC Supporters Registry\n"
|
| 250 |
"# Written by sponsor-logo intake + Stripe poll job.\n"
|
| 251 |
"# Schema: see commit history of this file.\n\n"
|
| 252 |
)
|
| 253 |
+
return header + yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _write_supporters(data: dict[str, Any]) -> None:
|
| 257 |
+
"""Atomic write β temp file + rename so a crash mid-write can't corrupt."""
|
| 258 |
+
SUPPORTERS_YAML.parent.mkdir(parents=True, exist_ok=True)
|
| 259 |
+
tmp = SUPPORTERS_YAML.with_suffix(SUPPORTERS_YAML.suffix + ".tmp")
|
| 260 |
with tmp.open("w", encoding="utf-8") as f:
|
| 261 |
+
f.write(_render_supporters(data))
|
| 262 |
tmp.replace(SUPPORTERS_YAML)
|
| 263 |
|
| 264 |
|
|
|
|
| 301 |
existing["stripe_sub"] = stripe_sub
|
| 302 |
|
| 303 |
|
| 304 |
+
def _git_output(*args: str) -> str:
|
| 305 |
+
proc = subprocess.run(["git", *args], cwd=WEBSITE_REPO, capture_output=True,
|
| 306 |
+
text=True, check=False, timeout=60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
if proc.returncode != 0:
|
| 308 |
+
raise RuntimeError(f"git {args[0]} failed: {(proc.stderr or proc.stdout).strip()[:300]}")
|
| 309 |
+
return proc.stdout
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _stage_upload(session_hash: str, ext: str, data: bytes, listing: dict[str, str]) -> tuple[Path, Path]:
|
| 313 |
+
"""Hold the logo and its proposed listing for the curator. Writes nothing to the site.
|
| 314 |
+
A re-upload for the same session replaces the held one, whatever its extension."""
|
| 315 |
+
READY_DIR.mkdir(parents=True, exist_ok=True)
|
| 316 |
+
base = f"{STAGE_PREFIX}{session_hash}"
|
| 317 |
+
image = READY_DIR / f"{base}.{ext}"
|
| 318 |
+
for other_ext in ALLOWED_MIME_EXT.values():
|
| 319 |
+
stale = READY_DIR / f"{base}.{other_ext}"
|
| 320 |
+
if other_ext != ext and stale.exists():
|
| 321 |
+
stale.unlink()
|
| 322 |
+
tmp = image.with_name(image.name + ".tmp")
|
| 323 |
+
tmp.write_bytes(data)
|
| 324 |
+
tmp.replace(image)
|
| 325 |
+
record = {
|
| 326 |
+
"held_at": datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 327 |
+
"reason": "Sponsor logo upload (Stripe checkout)",
|
| 328 |
+
"session_hash": session_hash,
|
| 329 |
+
"logo_file": image.name,
|
| 330 |
+
"logo_ext": ext,
|
| 331 |
+
"logo_sha256": hashlib.sha256(data).hexdigest(),
|
| 332 |
+
**listing,
|
| 333 |
+
}
|
| 334 |
+
stage = READY_DIR / f"{base}.json"
|
| 335 |
+
tmp = stage.with_name(stage.name + ".tmp")
|
| 336 |
+
tmp.write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
| 337 |
+
tmp.replace(stage)
|
| 338 |
+
return stage, image
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _send_logo_to_telegram(image: Path, caption: str) -> bool:
|
| 342 |
+
"""Show the curator the uploaded image itself. Telegram only: the ntfy backup carries no names."""
|
| 343 |
+
import config # noqa: PLC0415 β editorial root module, on sys.path in the server and with -m
|
| 344 |
+
|
| 345 |
+
token = getattr(config, "TELEGRAM_TOKEN", "")
|
| 346 |
+
chat_id = getattr(config, "TELEGRAM_CHAT_ID", "")
|
| 347 |
+
if not token or not chat_id:
|
| 348 |
+
return False
|
| 349 |
+
data = {"chat_id": str(chat_id), "caption": caption[:1000]}
|
| 350 |
+
thread_id = str(getattr(config, "TELEGRAM_THREAD_ID", "") or "").strip()
|
| 351 |
+
if thread_id:
|
| 352 |
+
data["message_thread_id"] = thread_id
|
| 353 |
+
try:
|
| 354 |
+
with httpx.Client(timeout=20.0) as client:
|
| 355 |
+
resp = client.post(f"https://api.telegram.org/bot{token}/sendDocument", data=data,
|
| 356 |
+
files={"document": (image.name, image.read_bytes())})
|
| 357 |
+
return resp.status_code == 200 and bool(resp.json().get("ok"))
|
| 358 |
+
except (httpx.HTTPError, ValueError) as e:
|
| 359 |
+
# Only the type: the request URL carries the bot token.
|
| 360 |
+
print(f" sponsor-logo: Telegram image send failed ({type(e).__name__})")
|
| 361 |
+
return False
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _ping_curator(stage: Path, image: Path, display_name: str) -> None:
|
| 365 |
+
import notify # noqa: PLC0415
|
| 366 |
+
|
| 367 |
+
message = ("READY TO PUBLISH (held for your approval): a Sponsor logo upload from /sponsor-onboarding. "
|
| 368 |
+
"Review and publish from the editorial-system directory: "
|
| 369 |
+
f".venv/bin/python -m intake.sponsor_logo --apply {stage}")
|
| 370 |
+
try:
|
| 371 |
+
pinged = bool(notify.send_to_curator(message, parse_mode=None))
|
| 372 |
+
except Exception as e: # noqa: BLE001 β the upload is held either way
|
| 373 |
+
print(f" sponsor-logo: curator ping error ({type(e).__name__})")
|
| 374 |
+
pinged = False
|
| 375 |
+
if not pinged:
|
| 376 |
+
print(f" sponsor-logo: curator ping failed; the upload is still held at {stage}")
|
| 377 |
+
if not _send_logo_to_telegram(image, f"Sponsor logo held for your approval: {display_name}"):
|
| 378 |
+
print(f" sponsor-logo: image not sent to Telegram; it is held at {image}")
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _apply_staged(stage: Path, push: bool = True) -> int:
|
| 382 |
+
"""Review a held upload and, on the typed word "publish", put it on the site."""
|
| 383 |
+
held = json.loads(stage.read_text(encoding="utf-8"))
|
| 384 |
+
session_hash = str(held.get("session_hash", ""))
|
| 385 |
+
ext = str(held.get("logo_ext", ""))
|
| 386 |
+
if not re.fullmatch(r"[0-9a-f]{8}", session_hash) or ext not in ALLOWED_MIME_EXT.values():
|
| 387 |
+
print("sponsor-logo: the held file is malformed; nothing written", file=sys.stderr)
|
| 388 |
+
return 1
|
| 389 |
+
image = stage.with_name(f"{STAGE_PREFIX}{session_hash}.{ext}")
|
| 390 |
+
data = image.read_bytes()
|
| 391 |
+
if hashlib.sha256(data).hexdigest() != held.get("logo_sha256"):
|
| 392 |
+
print("sponsor-logo: the held image changed since the upload; nothing written", file=sys.stderr)
|
| 393 |
+
return 1
|
| 394 |
+
mime = _sniff_mime(data[:64])
|
| 395 |
+
if ALLOWED_MIME_EXT.get(mime or "") != ext or (mime == "image/svg+xml" and not _svg_is_inert(data)):
|
| 396 |
+
print("sponsor-logo: the held image failed the format check; nothing written", file=sys.stderr)
|
| 397 |
+
return 1
|
| 398 |
+
if _git_output("status", "--porcelain").strip():
|
| 399 |
+
print("sponsor-logo: the website repo has uncommitted changes; refusing to apply over them", file=sys.stderr)
|
| 400 |
+
return 1
|
| 401 |
+
if push:
|
| 402 |
+
_git_output("pull", "--rebase", "origin", "main")
|
| 403 |
+
|
| 404 |
+
before = SUPPORTERS_YAML.read_text(encoding="utf-8") if SUPPORTERS_YAML.exists() else ""
|
| 405 |
+
supporters = _load_supporters()
|
| 406 |
+
out_name = f"{session_hash}.{ext}"
|
| 407 |
+
display_name = str(held.get("display_name", ""))
|
| 408 |
+
_upsert_sponsor(
|
| 409 |
+
supporters,
|
| 410 |
+
stripe_sub=str(held.get("stripe_sub", "")),
|
| 411 |
+
display_name=display_name,
|
| 412 |
+
website_url=str(held.get("website_url", "")),
|
| 413 |
+
logo_path=f"/supporter-logos/{out_name}",
|
| 414 |
+
valid_through=str(held.get("valid_through", "")),
|
| 415 |
)
|
| 416 |
+
after = _render_supporters(supporters)
|
| 417 |
+
logo_file = LOGO_DIR / out_name
|
| 418 |
+
if after == before and logo_file.exists() and logo_file.read_bytes() == data:
|
| 419 |
+
print(f"sponsor-logo: the site already shows {stage.name}; nothing to publish")
|
| 420 |
+
_mark_applied(stage, image)
|
| 421 |
+
return 0
|
| 422 |
+
sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
|
| 423 |
+
"supporters.yaml (live)", "supporters.yaml (after)"))
|
| 424 |
+
print(f"\nLogo: public/supporter-logos/{out_name} ({max(1, len(data) // 1024)} KB {ext}). "
|
| 425 |
+
f"The image went to Telegram with the ping; held copy: {image}")
|
| 426 |
+
if not sys.stdin.isatty():
|
| 427 |
+
print("sponsor-logo: --apply needs a terminal to confirm; nothing written", file=sys.stderr)
|
| 428 |
+
return 1
|
| 429 |
+
word = input('Type "publish" to write the logo and supporters.yaml and '
|
| 430 |
+
+ ("commit and push: " if push else "commit (no push): "))
|
| 431 |
+
if word.strip() != "publish":
|
| 432 |
+
print("sponsor-logo: not confirmed; nothing written")
|
| 433 |
+
return 1
|
| 434 |
+
|
| 435 |
+
LOGO_DIR.mkdir(parents=True, exist_ok=True)
|
| 436 |
+
tmp = logo_file.with_name(logo_file.name + ".tmp")
|
| 437 |
+
tmp.write_bytes(data)
|
| 438 |
+
tmp.replace(logo_file)
|
| 439 |
+
# An earlier upload under another extension would be left orphaned.
|
| 440 |
+
for other_ext in ALLOWED_MIME_EXT.values():
|
| 441 |
+
stale = LOGO_DIR / f"{session_hash}.{other_ext}"
|
| 442 |
+
if other_ext != ext and stale.exists():
|
| 443 |
+
stale.unlink()
|
| 444 |
+
_write_supporters(supporters)
|
| 445 |
+
_git_output("add", "-A", "--", "src/data/supporters.yaml", "public/supporter-logos")
|
| 446 |
+
_git_output("-c", "user.email=info@icsacinstitute.org", "-c", "user.name=ICSAC",
|
| 447 |
+
"commit", "-m", f"sponsors: add {display_name} logo")
|
| 448 |
+
if push:
|
| 449 |
+
_git_output("push", "origin", "main")
|
| 450 |
+
_mark_applied(stage, image)
|
| 451 |
+
print("sponsor-logo: published" if push else "sponsor-logo: committed (not pushed)")
|
| 452 |
+
return 0
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def _mark_applied(stage: Path, image: Path) -> None:
|
| 456 |
+
for p in (stage, image):
|
| 457 |
+
if p.exists():
|
| 458 |
+
p.rename(p.with_name(p.name + ".applied"))
|
| 459 |
|
| 460 |
|
| 461 |
def prefill_for_session(session_id: str) -> dict[str, Any]:
|
|
|
|
| 539 |
|
| 540 |
# Filename: opaque 8-char session-id hash. Stable for re-uploads.
|
| 541 |
session_hash = hashlib.sha256(session_id.encode()).hexdigest()[:8]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
+
# Held for the curator; nothing reaches the site until the curator publishes it.
|
| 544 |
+
stage, image = _stage_upload(session_hash, ext, chunk_bytes, {
|
| 545 |
+
"stripe_sub": info["stripe_sub"],
|
| 546 |
+
"display_name": display_name,
|
| 547 |
+
"website_url": website_url,
|
| 548 |
+
"valid_through": info["valid_through"],
|
| 549 |
+
})
|
| 550 |
+
_ping_curator(stage, image, display_name)
|
| 551 |
|
| 552 |
return {
|
| 553 |
"ok": True,
|
| 554 |
+
"held": True,
|
| 555 |
"display_name": display_name,
|
| 556 |
+
"received_at": int(time.time()),
|
|
|
|
| 557 |
}
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def main(argv: list[str] | None = None) -> int:
|
| 561 |
+
ap = argparse.ArgumentParser(description="Review and publish a held sponsor-logo upload.")
|
| 562 |
+
ap.add_argument("--apply", type=Path, required=True, metavar="FILE", help="the held sponsor-logo-<id>.json")
|
| 563 |
+
ap.add_argument("--no-push", action="store_true", help="commit but do not pull or push")
|
| 564 |
+
args = ap.parse_args(argv)
|
| 565 |
+
try:
|
| 566 |
+
return _apply_staged(args.apply, push=not args.no_push)
|
| 567 |
+
except Exception as e: # noqa: BLE001
|
| 568 |
+
print(f"sponsor-logo: apply FAILED: {getattr(e, 'detail', None) or e!r}", file=sys.stderr)
|
| 569 |
+
return 1
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
if __name__ == "__main__":
|
| 573 |
+
sys.exit(main())
|
tools/wrap-paper-pdf.py
CHANGED
|
@@ -1,286 +1,375 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Wrap an
|
| 3 |
-
|
| 4 |
-
Reads
|
| 5 |
-
renders a
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
Usage:
|
| 14 |
wrap-paper-pdf.py --slug the-dynamic-existence-threshold
|
| 15 |
wrap-paper-pdf.py --all
|
| 16 |
-
wrap-paper-pdf.py --slug X --fetch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
import argparse
|
|
|
|
|
|
|
|
|
|
| 20 |
import io
|
| 21 |
import json
|
| 22 |
import os
|
|
|
|
| 23 |
import sys
|
|
|
|
|
|
|
| 24 |
import urllib.request
|
| 25 |
-
from datetime import date
|
| 26 |
from pathlib import Path
|
| 27 |
|
| 28 |
import weasyprint
|
| 29 |
import pypdf
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
SITE = Path(_SITE)
|
| 35 |
-
LOGO = SITE / "public" / "logo_trans_ICSAC_notext.png"
|
| 36 |
-
ACCEPTED = SITE / "src" / "data" / "accepted.json"
|
| 37 |
-
OUT_DIR = SITE / "public" / "papers"
|
| 38 |
|
| 39 |
-
#
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
-
|
| 44 |
-
"""
|
| 45 |
-
if not iso:
|
| 46 |
-
return ""
|
| 47 |
-
y, m, d = iso.split("-")
|
| 48 |
-
return f"{m}/{d}/{y}"
|
| 49 |
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
def
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
def _find(slug: str) -> dict:
|
| 65 |
-
for p in _load_papers():
|
| 66 |
-
if p.get("slug") == slug:
|
| 67 |
-
return p
|
| 68 |
-
raise SystemExit(f"slug not found in accepted.json: {slug}")
|
| 69 |
|
|
|
|
| 70 |
|
| 71 |
-
def
|
| 72 |
-
"""
|
| 73 |
-
api = f"https://zenodo.org/api/records/{record_id}"
|
| 74 |
-
print(f" fetching Zenodo metadata: {api}")
|
| 75 |
-
with urllib.request.urlopen(api, timeout=30) as r:
|
| 76 |
-
meta = json.loads(r.read())
|
| 77 |
-
files = meta.get("files", [])
|
| 78 |
-
pdfs = [f for f in files if f.get("key", "").lower().endswith(".pdf")]
|
| 79 |
-
if not pdfs:
|
| 80 |
-
raise SystemExit(f"no PDF file on Zenodo record {record_id}")
|
| 81 |
-
# Pick the largest (most likely the manuscript over supplementary)
|
| 82 |
-
pdf = max(pdfs, key=lambda f: f.get("size", 0))
|
| 83 |
-
url = pdf["links"]["self"]
|
| 84 |
-
print(f" downloading: {url}")
|
| 85 |
-
with urllib.request.urlopen(url, timeout=120) as r, open(dest, "wb") as out:
|
| 86 |
-
out.write(r.read())
|
| 87 |
-
print(f" saved β {dest} ({dest.stat().st_size} bytes)")
|
| 88 |
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
body {
|
| 94 |
-
font-family: "EB Garamond", Georgia, "Times New Roman", serif;
|
| 95 |
-
font-size: 11pt;
|
| 96 |
-
line-height: 1.5;
|
| 97 |
-
color: #0A1929;
|
| 98 |
-
margin: 0;
|
| 99 |
-
}
|
| 100 |
|
| 101 |
-
.header {
|
| 102 |
-
display: flex;
|
| 103 |
-
align-items: center;
|
| 104 |
-
gap: 14pt;
|
| 105 |
-
padding-bottom: 12pt;
|
| 106 |
-
border-bottom: 1pt solid #8B5E3C;
|
| 107 |
-
}
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
}
|
| 113 |
|
| 114 |
-
.header-text {
|
| 115 |
-
display: flex;
|
| 116 |
-
flex-direction: column;
|
| 117 |
-
}
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
|
| 129 |
-
.header-tag {
|
| 130 |
-
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
| 131 |
-
font-size: 8.5pt;
|
| 132 |
-
color: #6E5C42;
|
| 133 |
-
margin-top: 3pt;
|
| 134 |
-
letter-spacing: 0.3pt;
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
.title-block {
|
| 138 |
-
margin: 32pt 0 0;
|
| 139 |
-
}
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
.authors {
|
| 150 |
-
font-size: 11.5pt;
|
| 151 |
-
color: #4B5563;
|
| 152 |
-
font-style: italic;
|
| 153 |
-
margin: 0 0 26pt;
|
| 154 |
-
}
|
| 155 |
|
| 156 |
-
|
| 157 |
-
display: grid;
|
| 158 |
-
grid-template-columns: 115pt 1fr;
|
| 159 |
-
gap: 7pt 16pt;
|
| 160 |
-
font-size: 10pt;
|
| 161 |
-
margin-bottom: 24pt;
|
| 162 |
-
}
|
| 163 |
|
| 164 |
-
.
|
| 165 |
-
|
| 166 |
-
color: #8B5E3C;
|
| 167 |
-
font-weight: 600;
|
| 168 |
-
letter-spacing: 0.6pt;
|
| 169 |
-
text-transform: uppercase;
|
| 170 |
-
font-size: 8pt;
|
| 171 |
-
margin: 0;
|
| 172 |
-
}
|
| 173 |
|
| 174 |
-
.meta dd {
|
| 175 |
-
color: #1f1f1f;
|
| 176 |
-
margin: 0;
|
| 177 |
-
word-break: break-word;
|
| 178 |
-
}
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
text-decoration: none;
|
| 183 |
-
}
|
| 184 |
|
| 185 |
-
.venue {
|
| 186 |
-
padding: 12pt 14pt;
|
| 187 |
-
background-color: rgba(139, 94, 60, 0.06);
|
| 188 |
-
border-left: 2pt solid #8B5E3C;
|
| 189 |
-
font-size: 10pt;
|
| 190 |
-
line-height: 1.55;
|
| 191 |
-
margin-bottom: 22pt;
|
| 192 |
-
}
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
.record {
|
| 199 |
-
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
| 200 |
-
font-size: 9pt;
|
| 201 |
-
color: #4B5563;
|
| 202 |
-
margin-bottom: 18pt;
|
| 203 |
-
}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
}
|
| 214 |
|
| 215 |
-
.record a {
|
| 216 |
-
color: #8B5E3C;
|
| 217 |
-
text-decoration: none;
|
| 218 |
-
word-break: break-all;
|
| 219 |
-
}
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
"""
|
| 234 |
|
| 235 |
|
| 236 |
-
def
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
authors = paper.get("authors") or []
|
| 241 |
-
doi = paper.get("doi") or ""
|
| 242 |
-
accepted = paper.get("accepted_date") or ""
|
| 243 |
-
sub_id = paper.get("sub_id") or "β"
|
| 244 |
-
|
| 245 |
-
doi_url = f"https://doi.org/{doi.lstrip('doi:').strip()}" if doi else ""
|
| 246 |
-
record_url = f"https://icsacinstitute.org/publications/{slug}"
|
| 247 |
-
|
| 248 |
-
# Logo as file:// URI so WeasyPrint loads it without fetching
|
| 249 |
-
logo_uri = f"file://{LOGO}"
|
| 250 |
-
|
| 251 |
html = f"""<!DOCTYPE html>
|
| 252 |
-
<html><head><meta charset="utf-8"><title>{title}</title>
|
| 253 |
-
<style>{_COVER_CSS}</style>
|
| 254 |
</head><body>
|
| 255 |
-
<div class="
|
| 256 |
-
<img src="{
|
| 257 |
-
<div
|
| 258 |
-
<div class="
|
| 259 |
-
<div class="
|
|
|
|
| 260 |
</div>
|
| 261 |
</div>
|
| 262 |
|
| 263 |
-
<div class="title
|
| 264 |
-
|
| 265 |
-
<div class="authors">{_escape(_authors_str(authors))}</div>
|
| 266 |
-
</div>
|
| 267 |
|
| 268 |
<dl class="meta">
|
| 269 |
-
<dt>DOI</dt><dd>{_escape(doi)}</dd>
|
| 270 |
-
<dt>
|
| 271 |
-
<dt>License</dt><dd>{DEFAULT_LICENSE}</dd>
|
| 272 |
-
<dt>Submission ID</dt><dd>{_escape(sub_id)}</dd>
|
| 273 |
</dl>
|
| 274 |
|
| 275 |
<div class="venue">
|
| 276 |
-
<strong>Reviewed by the ICSAC panel</strong>
|
| 277 |
</div>
|
| 278 |
|
| 279 |
-
<div class="record">
|
| 280 |
-
<span class="
|
| 281 |
<a href="{record_url}">{record_url}</a>
|
| 282 |
</div>
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
<div class="note">
|
| 285 |
This cover page is added by the Institute for Complexity Science and Advanced Computing. It does not modify the author's manuscript that follows. The author retains copyright under the license indicated; ICSAC publishes the editorial record but does not claim ownership of the work.
|
| 286 |
</div>
|
|
@@ -288,61 +377,145 @@ def _render_cover(paper: dict) -> bytes:
|
|
| 288 |
return weasyprint.HTML(string=html).write_pdf()
|
| 289 |
|
| 290 |
|
| 291 |
-
|
| 292 |
-
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
-
def wrap_one(paper: dict, fetch: bool = False) -> Path:
|
| 296 |
-
rid = paper.get("record_id")
|
| 297 |
-
slug = paper["slug"]
|
| 298 |
-
if not rid:
|
| 299 |
-
raise SystemExit(f"paper has no record_id (upload route not yet supported): {slug}")
|
| 300 |
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
if not source.exists():
|
| 303 |
-
if fetch:
|
| 304 |
print(f" source PDF missing, fetching from Zenodo: {source}")
|
| 305 |
-
_zenodo_fetch_pdf(
|
| 306 |
else:
|
| 307 |
-
raise
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
-
out = OUT_DIR / f"{rid}-icsac.pdf"
|
| 313 |
writer = pypdf.PdfWriter()
|
| 314 |
-
writer.append(
|
| 315 |
-
writer.append(
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
return out
|
| 320 |
|
| 321 |
|
| 322 |
-
def main():
|
| 323 |
-
ap = argparse.ArgumentParser()
|
| 324 |
ap.add_argument("--slug")
|
| 325 |
ap.add_argument("--all", action="store_true")
|
| 326 |
-
ap.add_argument("--fetch", action="store_true",
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
|
|
|
| 332 |
if args.all:
|
| 333 |
-
papers =
|
| 334 |
-
for p in papers:
|
| 335 |
-
if not p.get("record_id"):
|
| 336 |
-
print(f"SKIP {p['slug']} (no record_id)")
|
| 337 |
-
continue
|
| 338 |
-
print(f"\nβ {p['slug']}")
|
| 339 |
-
wrap_one(p, fetch=args.fetch)
|
| 340 |
-
elif args.slug:
|
| 341 |
-
print(f"β {args.slug}")
|
| 342 |
-
wrap_one(_find(args.slug), fetch=args.fetch)
|
| 343 |
else:
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
|
| 347 |
if __name__ == "__main__":
|
| 348 |
-
main()
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Wrap an accepted Persistence article with a 1-page ICSAC cover.
|
| 3 |
+
|
| 4 |
+
Reads the article from the website's src/data/accepted.json and the volume
|
| 5 |
+
table from src/lib/persistence.ts, renders a cover the size of the author's
|
| 6 |
+
first page (Letter or A4) via WeasyPrint, and prepends it to the author's PDF
|
| 7 |
+
via pypdf. The author's PDF is public/papers/<key>.pdf and the result is
|
| 8 |
+
public/papers/<key>-icsac.pdf, where <key> is the Zenodo record_id for papers
|
| 9 |
+
deposited there and the submission ID (ICSAC-SUB-NNNNN) for papers registered
|
| 10 |
+
under the Institute's DOI prefix.
|
| 11 |
+
|
| 12 |
+
The author's manuscript is NEVER modified: the cover is a new first page only,
|
| 13 |
+
and the bare <key>.pdf stays in place for Scholar, the Crossref link and
|
| 14 |
+
archival tooling. After writing, the result is read back: page 1 must be the
|
| 15 |
+
cover and every later page must carry the source page's content unchanged.
|
| 16 |
+
|
| 17 |
+
Dates: --accepted / --published when given (register --live passes the dates
|
| 18 |
+
it deposited with Crossref). Otherwise an Institute DOI reads both from
|
| 19 |
+
Crossref, and a Zenodo DOI takes the acceptance date from the registry and the
|
| 20 |
+
first publication date from the Zenodo record. A date or licence that can't be
|
| 21 |
+
read stops the run: the cover never guesses.
|
| 22 |
|
| 23 |
Usage:
|
| 24 |
wrap-paper-pdf.py --slug the-dynamic-existence-threshold
|
| 25 |
wrap-paper-pdf.py --all
|
| 26 |
+
wrap-paper-pdf.py --slug X --fetch # download a missing Zenodo source PDF
|
| 27 |
+
wrap-paper-pdf.py --slug X --accepted 2026-03-02 --published 2026-03-03
|
| 28 |
+
wrap-paper-pdf.py --slug X --out DIR # write the result to DIR instead
|
| 29 |
+
|
| 30 |
+
Exit 0 when every cover asked for was written; 1 when any was not ("cover not
|
| 31 |
+
made: <reason>" on stderr).
|
| 32 |
"""
|
| 33 |
from __future__ import annotations
|
| 34 |
import argparse
|
| 35 |
+
import base64
|
| 36 |
+
import datetime as _dt
|
| 37 |
+
import hashlib
|
| 38 |
import io
|
| 39 |
import json
|
| 40 |
import os
|
| 41 |
+
import re
|
| 42 |
import sys
|
| 43 |
+
import tempfile
|
| 44 |
+
import urllib.parse
|
| 45 |
import urllib.request
|
|
|
|
| 46 |
from pathlib import Path
|
| 47 |
|
| 48 |
import weasyprint
|
| 49 |
import pypdf
|
| 50 |
+
from PIL import Image
|
| 51 |
|
| 52 |
+
SITE_URL = "https://icsacinstitute.org"
|
| 53 |
+
USER_AGENT = "ICSAC-cover-tool/2.0 (+https://icsacinstitute.org; mailto:info@icsacinstitute.org)"
|
| 54 |
+
PUBLISHER = "Institute for Complexity Science and Advanced Computing"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
# Same labels as the article page (src/pages/publications/[slug].astro).
|
| 57 |
+
LICENSE_LABELS = {
|
| 58 |
+
"https://creativecommons.org/licenses/by/4.0/": "CC BY 4.0",
|
| 59 |
+
"https://creativecommons.org/licenses/by-sa/4.0/": "CC BY-SA 4.0",
|
| 60 |
+
"https://creativecommons.org/publicdomain/zero/1.0/": "CC0 1.0",
|
| 61 |
+
}
|
| 62 |
+
# Zenodo licence ids -> the URL the registry would carry.
|
| 63 |
+
ZENODO_LICENSES = {
|
| 64 |
+
"cc-by-4.0": "https://creativecommons.org/licenses/by/4.0/",
|
| 65 |
+
"cc-by-sa-4.0": "https://creativecommons.org/licenses/by-sa/4.0/",
|
| 66 |
+
"cc0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 67 |
+
}
|
| 68 |
+
LOGO_HEIGHT_PT = 46
|
| 69 |
+
LOGO_PPI = 300
|
| 70 |
|
| 71 |
|
| 72 |
+
class CoverError(Exception):
|
| 73 |
+
"""This paper's cover can't be made; nothing is written for it."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
+
class Site:
|
| 77 |
+
def __init__(self, root: Path):
|
| 78 |
+
self.root = Path(root)
|
| 79 |
+
self.accepted = self.root / "src" / "data" / "accepted.json"
|
| 80 |
+
self.persistence_ts = self.root / "src" / "lib" / "persistence.ts"
|
| 81 |
+
self.papers = self.root / "public" / "papers"
|
| 82 |
+
self.logo = self.root / "public" / "logo_trans_ICSAC_notext.png"
|
| 83 |
+
self.fonts = self.root / "public" / "fonts"
|
| 84 |
|
| 85 |
+
def load_papers(self) -> list[dict]:
|
| 86 |
+
d = json.loads(self.accepted.read_text(encoding="utf-8"))
|
| 87 |
+
return d if isinstance(d, list) else d.get("papers", [])
|
| 88 |
|
| 89 |
+
def find(self, slug: str) -> dict:
|
| 90 |
+
for p in self.load_papers():
|
| 91 |
+
if p.get("slug") == slug:
|
| 92 |
+
return p
|
| 93 |
+
raise CoverError(f"slug not found in accepted.json: {slug}")
|
| 94 |
|
| 95 |
+
def journal(self) -> dict:
|
| 96 |
+
"""{title, volumes (oldest first), prefix} from persistence.ts, the file the
|
| 97 |
+
article page reads: the journal title, the volume table, and the DOI
|
| 98 |
+
prefix its isInstituteDoi() treats as the Institute's own."""
|
| 99 |
+
ts = self.persistence_ts.read_text(encoding="utf-8")
|
| 100 |
+
t = re.search(r"PERSISTENCE_TITLE\s*=\s*'([^']+)'", ts)
|
| 101 |
+
m = re.search(r"PERSISTENCE_VOLUMES[^=]*=\s*\[(.*?)\];", ts, re.S)
|
| 102 |
+
rows = re.findall(
|
| 103 |
+
r"\{\s*volume:\s*'([^']+)',\s*from:\s*'(\d{4}-\d{2}-\d{2})',\s*year:\s*'(\d{4})'\s*,?\s*\}",
|
| 104 |
+
m.group(1) if m else "")
|
| 105 |
+
p = re.search(r"function isInstituteDoi\b.*?/\^10\\\.(\d{4,9})\\/", ts, re.S)
|
| 106 |
+
if not t or not rows or not p:
|
| 107 |
+
raise CoverError(f"couldn't read the journal title, volume table or DOI prefix from {self.persistence_ts}")
|
| 108 |
+
vols = sorted(({"volume": v, "from": f, "year": y} for v, f, y in rows), key=lambda r: r["from"])
|
| 109 |
+
return {"title": t.group(1), "volumes": vols, "prefix": f"10.{p.group(1)}"}
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
# ββ the citation, ported from the site's src/lib/cite.ts + persistence.ts ββ
|
| 113 |
|
| 114 |
+
def bare_doi(doi: str) -> str:
|
| 115 |
+
return re.sub(r"^doi:", "", doi or "", flags=re.I).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
+
def is_institute_doi(doi: str, prefix: str) -> bool:
|
| 119 |
+
return bare_doi(doi).lower().startswith(prefix.lower() + "/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
def article_number_for(doi: str, prefix: str) -> str | None:
|
| 123 |
+
m = re.match(re.escape(prefix) + r"/icsac\.\d{4}\.(\d{3,})$", bare_doi(doi), re.I)
|
| 124 |
+
return m.group(1) if m else None
|
|
|
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
def volume_for(accepted: str, volumes: list[dict]) -> dict | None:
|
| 128 |
+
d = (accepted or "")[:10]
|
| 129 |
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", d):
|
| 130 |
+
return None
|
| 131 |
+
hit = None
|
| 132 |
+
for row in volumes:
|
| 133 |
+
if d >= row["from"]:
|
| 134 |
+
hit = row
|
| 135 |
+
return hit
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
def format_author_apa(name: str) -> str:
|
| 139 |
+
t = name.strip()
|
| 140 |
+
if not t:
|
| 141 |
+
return ""
|
| 142 |
+
if "," in t:
|
| 143 |
+
return t
|
| 144 |
+
parts = t.split()
|
| 145 |
+
if len(parts) == 1:
|
| 146 |
+
return parts[0]
|
| 147 |
+
initials = [p if p.endswith(".") else p[0].upper() + "." for p in parts[:-1]]
|
| 148 |
+
return f"{parts[-1]}, {' '.join(initials)}"
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def authors_apa_list(authors: list[str]) -> str:
|
| 152 |
+
f = [format_author_apa(a) for a in authors]
|
| 153 |
+
if not f:
|
| 154 |
+
return "Anonymous"
|
| 155 |
+
if len(f) == 1:
|
| 156 |
+
return f[0]
|
| 157 |
+
if len(f) == 2:
|
| 158 |
+
return f"{f[0]} & {f[1]}"
|
| 159 |
+
return ", ".join(f[:-1]) + ", & " + f[-1]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def doi_url(doi: str) -> str:
|
| 163 |
+
return "https://doi.org/" + re.sub(r"^doi:", "", doi, flags=re.I)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def cite_as(paper: dict, j: dict) -> str:
|
| 167 |
+
"""The page's "The work" plain-text citation (cite.ts plainTextWork)."""
|
| 168 |
+
authors = authors_apa_list(paper.get("authors") or [])
|
| 169 |
+
year = (paper.get("accepted_date") or "")[:4]
|
| 170 |
+
if is_institute_doi(paper["doi"], j["prefix"]):
|
| 171 |
+
vol = volume_for(paper.get("accepted_date") or "", j["volumes"])
|
| 172 |
+
art = article_number_for(paper["doi"], j["prefix"])
|
| 173 |
+
loc = j["title"] + (f", {vol['volume']}" if vol else "") + (f", Article {art}" if art else "")
|
| 174 |
+
return f"{authors} ({year}). {paper['title']}. {loc}. {doi_url(paper['doi'])}"
|
| 175 |
+
return f"{authors} ({year}). {paper['title']}. Zenodo. {doi_url(paper['doi'])}. Reviewed by the ICSAC panel."
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ββ record lookups (dates, licence) ββββββββββββββββββββββββββββββββββββββββββ
|
| 179 |
+
|
| 180 |
+
def _get_json(url: str) -> dict:
|
| 181 |
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
| 182 |
+
with urllib.request.urlopen(req, timeout=30) as r:
|
| 183 |
+
return json.loads(r.read())
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _iso(value: str, what: str) -> _dt.date:
|
| 187 |
+
try:
|
| 188 |
+
return _dt.date.fromisoformat((value or "")[:10])
|
| 189 |
+
except ValueError:
|
| 190 |
+
raise CoverError(f"{what} isn't a date: {value!r}")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _crossref_date(msg: dict, *keys: str) -> _dt.date | None:
|
| 194 |
+
for k in keys:
|
| 195 |
+
parts = ((msg.get(k) or {}).get("date-parts") or [[]])[0]
|
| 196 |
+
if len(parts) == 3:
|
| 197 |
+
return _dt.date(*parts)
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def resolve_record(paper: dict, prefix: str, accepted: str | None, published: str | None) -> dict:
|
| 202 |
+
"""{accepted, published, published_where, license_url}, each from its record."""
|
| 203 |
+
doi = bare_doi(paper["doi"])
|
| 204 |
+
out = {"accepted": _iso(accepted, "--accepted") if accepted else None,
|
| 205 |
+
"published": _iso(published, "--published") if published else None,
|
| 206 |
+
"published_where": None,
|
| 207 |
+
"license_url": paper.get("license_url") or None}
|
| 208 |
+
if is_institute_doi(doi, prefix):
|
| 209 |
+
if not (out["accepted"] and out["published"]):
|
| 210 |
+
try:
|
| 211 |
+
msg = _get_json("https://api.crossref.org/works/" + urllib.parse.quote(doi, safe="/"))["message"]
|
| 212 |
+
except Exception as exc:
|
| 213 |
+
raise CoverError(f"couldn't read the dates from Crossref for {doi} ({type(exc).__name__}: "
|
| 214 |
+
f"{str(exc)[:80]}); pass --accepted and --published")
|
| 215 |
+
out["accepted"] = out["accepted"] or _crossref_date(msg, "accepted")
|
| 216 |
+
out["published"] = out["published"] or _crossref_date(msg, "published-online", "published")
|
| 217 |
+
if not out["license_url"]:
|
| 218 |
+
lic = [x.get("URL") for x in msg.get("license") or [] if x.get("content-version") == "vor"]
|
| 219 |
+
out["license_url"] = lic[0] if lic else None
|
| 220 |
+
else:
|
| 221 |
+
out["accepted"] = out["accepted"] or _iso(paper.get("accepted_date"), "accepted_date")
|
| 222 |
+
out["published_where"] = "Zenodo" if paper.get("record_id") else None
|
| 223 |
+
if paper.get("record_id") and not (out["published"] and out["license_url"]):
|
| 224 |
+
try:
|
| 225 |
+
meta = _get_json(f"https://zenodo.org/api/records/{paper['record_id']}")["metadata"]
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
raise CoverError(f"couldn't read Zenodo record {paper['record_id']} ({type(exc).__name__}: "
|
| 228 |
+
f"{str(exc)[:80]}); pass --published")
|
| 229 |
+
if not out["published"] and meta.get("publication_date"):
|
| 230 |
+
out["published"] = _iso(meta["publication_date"], "Zenodo publication_date")
|
| 231 |
+
if not out["license_url"]:
|
| 232 |
+
out["license_url"] = ZENODO_LICENSES.get(((meta.get("license") or {}).get("id") or "").lower())
|
| 233 |
+
if not out["accepted"]:
|
| 234 |
+
raise CoverError(f"no acceptance date on record for {doi}; pass --accepted")
|
| 235 |
+
if not out["published"]:
|
| 236 |
+
raise CoverError(f"no publication date on record for {doi}; pass --published")
|
| 237 |
+
if out["license_url"] not in LICENSE_LABELS:
|
| 238 |
+
raise CoverError(f"licence for {doi} is {out['license_url'] or 'not on record'}; the cover names "
|
| 239 |
+
f"only {', '.join(LICENSE_LABELS.values())}")
|
| 240 |
+
return out
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
# ββ rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
+
def _us(d: _dt.date) -> str:
|
| 246 |
+
return d.strftime("%m/%d/%Y")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
+
def _escape(s: str) -> str:
|
| 250 |
+
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
+
def _logo_data_uri(path: Path) -> str:
|
| 254 |
+
"""The mark cropped to its ink and scaled to LOGO_PPI at the printed size, so
|
| 255 |
+
the cover doesn't carry (and viewers don't decode) the 2568 px master."""
|
| 256 |
+
im = Image.open(path).convert("RGBA")
|
| 257 |
+
im = im.crop(im.getbbox() or (0, 0, *im.size))
|
| 258 |
+
h = round(LOGO_HEIGHT_PT / 72 * LOGO_PPI)
|
| 259 |
+
im = im.resize((round(im.width * h / im.height), h), Image.LANCZOS)
|
| 260 |
+
buf = io.BytesIO()
|
| 261 |
+
im.save(buf, "PNG", optimize=True)
|
| 262 |
+
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
+
def _font_faces(fonts: Path) -> str:
|
| 266 |
+
faces = [("EB Garamond", "normal", "400 700", "eb-garamond-latin.woff2"),
|
| 267 |
+
("EB Garamond", "italic", "400", "eb-garamond-italic-latin.woff2"),
|
| 268 |
+
("Inter", "normal", "400 700", "inter-latin.woff2")]
|
| 269 |
+
return "".join(
|
| 270 |
+
f"@font-face {{ font-family: '{fam}'; font-style: {style}; font-weight: {weight}; "
|
| 271 |
+
f"src: url('{(fonts / f).as_uri()}') format('woff2'); }}\n"
|
| 272 |
+
for fam, style, weight, f in faces if (fonts / f).exists())
|
|
|
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
+
_COVER_CSS = """
|
| 276 |
+
@page { margin: 0.75in 0.85in 1.25in 0.85in; }
|
| 277 |
+
body { font-family: "EB Garamond", Georgia, "Times New Roman", serif; font-size: 11pt;
|
| 278 |
+
line-height: 1.5; color: #0A1929; margin: 0; }
|
| 279 |
+
.sans { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; }
|
| 280 |
+
.mast { display: flex; align-items: center; gap: 14pt; padding-bottom: 12pt;
|
| 281 |
+
border-bottom: 1pt solid #8B5E3C; }
|
| 282 |
+
.mast img { height: 46pt; width: auto; }
|
| 283 |
+
.journal { font-size: 25pt; font-weight: 600; line-height: 1.05; color: #0A1929; letter-spacing: 0.2pt; }
|
| 284 |
+
.issue { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; font-size: 9pt; font-weight: 600;
|
| 285 |
+
letter-spacing: 1.1pt; text-transform: uppercase; color: #8B5E3C; margin-top: 5pt; }
|
| 286 |
+
.org { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; font-size: 8.5pt; color: #6E5C42;
|
| 287 |
+
margin-top: 2pt; letter-spacing: 0.2pt; }
|
| 288 |
+
.title { font-size: 20pt; font-weight: 600; line-height: 1.22; margin: 30pt 0 10pt; color: #0A1929; }
|
| 289 |
+
.authors { font-size: 11.5pt; color: #4B5563; font-style: italic; margin: 0 0 24pt; }
|
| 290 |
+
.meta { display: grid; grid-template-columns: 115pt 1fr; gap: 7pt 16pt; font-size: 10.5pt; margin: 0 0 22pt; }
|
| 291 |
+
.meta dt { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; color: #8B5E3C; font-weight: 600;
|
| 292 |
+
letter-spacing: 0.6pt; text-transform: uppercase; font-size: 8pt; margin: 0; padding-top: 2pt; }
|
| 293 |
+
.meta dd { color: #1f1f1f; margin: 0; overflow-wrap: anywhere; }
|
| 294 |
+
a { color: inherit; text-decoration: none; }
|
| 295 |
+
.venue { padding: 11pt 14pt; background-color: rgba(139, 94, 60, 0.06); border-left: 2pt solid #8B5E3C;
|
| 296 |
+
font-size: 10.5pt; line-height: 1.55; margin-bottom: 20pt; }
|
| 297 |
+
.venue strong { color: #8B5E3C; }
|
| 298 |
+
.block { margin-bottom: 16pt; }
|
| 299 |
+
.label { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; letter-spacing: 0.6pt;
|
| 300 |
+
text-transform: uppercase; font-size: 8pt; color: #8B5E3C; font-weight: 600; display: block;
|
| 301 |
+
margin-bottom: 3pt; }
|
| 302 |
+
.record a { font-family: "Inter", "Helvetica Neue", Arial, sans-serif; font-size: 9pt; color: #8B5E3C;
|
| 303 |
+
overflow-wrap: anywhere; }
|
| 304 |
+
.cite p { margin: 0; font-size: 10.5pt; line-height: 1.5; overflow-wrap: anywhere; }
|
| 305 |
+
.note { position: fixed; bottom: -0.65in; left: 0; right: 0; padding-top: 8pt; border-top: 0.5pt solid #ccc;
|
| 306 |
+
font-family: "Inter", "Helvetica Neue", Arial, sans-serif; font-size: 7.5pt; line-height: 1.45;
|
| 307 |
+
color: #6b6b6b; }
|
| 308 |
"""
|
| 309 |
|
| 310 |
|
| 311 |
+
def _page_size(page) -> str:
|
| 312 |
+
"""The author's first page size, so the file has one page width (A4 papers
|
| 313 |
+
get an A4 cover); Letter when that page is landscape or an odd size."""
|
| 314 |
+
w, h = float(page.mediabox.width), float(page.mediabox.height)
|
| 315 |
+
if (page.get("/Rotate") or 0) % 180:
|
| 316 |
+
w, h = h, w
|
| 317 |
+
return f"{w:.2f}pt {h:.2f}pt" if 500 <= w <= 700 and 700 <= h <= 900 else "Letter"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def render_cover(paper: dict, rec: dict, site: Site, size: str = "Letter") -> bytes:
|
| 321 |
+
j = site.journal()
|
| 322 |
+
doi = bare_doi(paper["doi"])
|
| 323 |
+
vol = volume_for(paper.get("accepted_date") or "", j["volumes"])
|
| 324 |
+
art = article_number_for(doi, j["prefix"])
|
| 325 |
+
issue = " · ".join(x for x in (
|
| 326 |
+
f"Volume {_escape(vol['volume'])} ({_escape(vol['year'])})" if vol else "",
|
| 327 |
+
f"Article {_escape(art)}" if art else "") if x)
|
| 328 |
+
record_url = f"{SITE_URL}/publications/{paper['slug']}"
|
| 329 |
+
if rec["published_where"]:
|
| 330 |
+
pub_dt, pub_dd = "First published", f"{_us(rec['published'])}, on {_escape(rec['published_where'])}"
|
| 331 |
+
else:
|
| 332 |
+
pub_dt, pub_dd = "Published online", _us(rec["published"])
|
| 333 |
+
# The two dates in the order they happened: a Zenodo deposit predates its acceptance.
|
| 334 |
+
dates = [("Accepted", _us(rec["accepted"])), (pub_dt, pub_dd)]
|
| 335 |
+
if rec["published"] < rec["accepted"]:
|
| 336 |
+
dates.reverse()
|
| 337 |
authors = paper.get("authors") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
html = f"""<!DOCTYPE html>
|
| 339 |
+
<html lang="en"><head><meta charset="utf-8"><title>{_escape(paper['title'])}</title>
|
| 340 |
+
<style>{_font_faces(site.fonts)}{_COVER_CSS}@page {{ size: {size}; }}</style>
|
| 341 |
</head><body>
|
| 342 |
+
<div class="mast">
|
| 343 |
+
<img src="{_logo_data_uri(site.logo)}" alt="" />
|
| 344 |
+
<div>
|
| 345 |
+
<div class="journal">{_escape(j['title'])}</div>
|
| 346 |
+
{f'<div class="issue">{issue}</div>' if issue else ''}
|
| 347 |
+
<div class="org">{PUBLISHER}</div>
|
| 348 |
</div>
|
| 349 |
</div>
|
| 350 |
|
| 351 |
+
<div class="title">{_escape(paper['title'])}</div>
|
| 352 |
+
<div class="authors">{_escape(', '.join(authors) or 'Anonymous')}</div>
|
|
|
|
|
|
|
| 353 |
|
| 354 |
<dl class="meta">
|
| 355 |
+
<dt>DOI</dt><dd><a href="{_escape(doi_url(doi))}">{_escape(doi)}</a></dd>
|
| 356 |
+
{''.join(f' <dt>{t}</dt><dd>{d}</dd>' + chr(10) for t, d in dates)} <dt>License</dt><dd><a href="{_escape(rec['license_url'])}">{LICENSE_LABELS[rec['license_url']]}</a></dd>
|
|
|
|
|
|
|
| 357 |
</dl>
|
| 358 |
|
| 359 |
<div class="venue">
|
| 360 |
+
<strong>Reviewed by the ICSAC panel.</strong> The full editorial record, with the AI panel reviews, the Review Quality Control audit and the curation team’s verdict, is public at the URL below.
|
| 361 |
</div>
|
| 362 |
|
| 363 |
+
<div class="block record">
|
| 364 |
+
<span class="label">Editorial record</span>
|
| 365 |
<a href="{record_url}">{record_url}</a>
|
| 366 |
</div>
|
| 367 |
|
| 368 |
+
<div class="block cite">
|
| 369 |
+
<span class="label">Cite as</span>
|
| 370 |
+
<p>{_escape(cite_as(paper, j))}</p>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
<div class="note">
|
| 374 |
This cover page is added by the Institute for Complexity Science and Advanced Computing. It does not modify the author's manuscript that follows. The author retains copyright under the license indicated; ICSAC publishes the editorial record but does not claim ownership of the work.
|
| 375 |
</div>
|
|
|
|
| 377 |
return weasyprint.HTML(string=html).write_pdf()
|
| 378 |
|
| 379 |
|
| 380 |
+
# ββ wrapping βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 381 |
|
| 382 |
+
def _zenodo_fetch_pdf(record_id: str, dest: Path) -> None:
|
| 383 |
+
"""Download the primary PDF file from a Zenodo record."""
|
| 384 |
+
files = _get_json(f"https://zenodo.org/api/records/{record_id}").get("files", [])
|
| 385 |
+
pdfs = [f for f in files if f.get("key", "").lower().endswith(".pdf")]
|
| 386 |
+
if not pdfs:
|
| 387 |
+
raise CoverError(f"no PDF file on Zenodo record {record_id}")
|
| 388 |
+
pdf = max(pdfs, key=lambda f: f.get("size", 0)) # the manuscript over a supplement
|
| 389 |
+
req = urllib.request.Request(pdf["links"]["self"], headers={"User-Agent": USER_AGENT})
|
| 390 |
+
with urllib.request.urlopen(req, timeout=120) as r:
|
| 391 |
+
_write_atomic(dest, r.read())
|
| 392 |
+
print(f" saved {dest} ({dest.stat().st_size} bytes)")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _write_atomic(path: Path, data: bytes) -> None:
|
| 396 |
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
|
| 397 |
+
try:
|
| 398 |
+
with os.fdopen(fd, "wb") as f:
|
| 399 |
+
f.write(data)
|
| 400 |
+
os.chmod(tmp, (path.stat().st_mode & 0o777) if path.exists() else 0o644)
|
| 401 |
+
os.replace(tmp, path)
|
| 402 |
+
except BaseException:
|
| 403 |
+
if os.path.exists(tmp):
|
| 404 |
+
os.unlink(tmp)
|
| 405 |
+
raise
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _header_version(reader: pypdf.PdfReader) -> tuple[int, int]:
|
| 409 |
+
m = re.match(r"%PDF-(\d+)\.(\d+)", reader.pdf_header or "")
|
| 410 |
+
return (int(m.group(1)), int(m.group(2))) if m else (1, 3)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _content_digest(page) -> str:
|
| 414 |
+
c = page.get_contents()
|
| 415 |
+
return hashlib.sha256(c.get_data() if c is not None else b"").hexdigest()
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
+
def pdf_key(paper: dict) -> str | None:
|
| 419 |
+
return paper.get("record_id") or paper.get("sub_id") or None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def wrap_one(paper: dict, site: Site, *, fetch: bool = False, accepted: str | None = None,
|
| 423 |
+
published: str | None = None, out_dir: Path | None = None) -> Path:
|
| 424 |
+
slug = paper["slug"]
|
| 425 |
+
key = pdf_key(paper)
|
| 426 |
+
if not key:
|
| 427 |
+
raise CoverError(f"{slug} has neither a Zenodo record_id nor a submission ID, so no PDF of it is on the site")
|
| 428 |
+
source = site.papers / f"{key}.pdf"
|
| 429 |
if not source.exists():
|
| 430 |
+
if fetch and paper.get("record_id"):
|
| 431 |
print(f" source PDF missing, fetching from Zenodo: {source}")
|
| 432 |
+
_zenodo_fetch_pdf(paper["record_id"], source)
|
| 433 |
else:
|
| 434 |
+
raise CoverError(f"source PDF missing: {source}"
|
| 435 |
+
+ (" (use --fetch to download)" if paper.get("record_id") else ""))
|
| 436 |
+
src_sha = hashlib.sha256(source.read_bytes()).hexdigest()
|
| 437 |
+
src = pypdf.PdfReader(str(source))
|
| 438 |
+
if src.is_encrypted:
|
| 439 |
+
raise CoverError(f"{source.name} is encrypted; the cover tool won't rewrite it")
|
| 440 |
+
|
| 441 |
+
rec = resolve_record(paper, site.journal()["prefix"], accepted, published)
|
| 442 |
+
print(f" rendering cover for {slug} (accepted {rec['accepted']}, published {rec['published']}"
|
| 443 |
+
f"{', on ' + rec['published_where'] if rec['published_where'] else ''}, {LICENSE_LABELS[rec['license_url']]})")
|
| 444 |
+
cover = pypdf.PdfReader(io.BytesIO(render_cover(paper, rec, site, _page_size(src.pages[0]))))
|
| 445 |
+
if len(cover.pages) != 1:
|
| 446 |
+
raise CoverError(f"the cover for {slug} rendered to {len(cover.pages)} pages, not 1")
|
| 447 |
|
|
|
|
| 448 |
writer = pypdf.PdfWriter()
|
| 449 |
+
writer.append(cover)
|
| 450 |
+
writer.append(src)
|
| 451 |
+
major, minor = max(_header_version(src), _header_version(cover))
|
| 452 |
+
writer.pdf_header = f"%PDF-{major}.{minor}"
|
| 453 |
+
writer.add_metadata({"/Title": paper["title"], "/Author": ", ".join(paper.get("authors") or [])})
|
| 454 |
+
buf = io.BytesIO()
|
| 455 |
+
writer.write(buf)
|
| 456 |
+
|
| 457 |
+
# Read the result back before it replaces anything: the cover first, then the
|
| 458 |
+
# author's pages with their content streams unchanged.
|
| 459 |
+
got = pypdf.PdfReader(io.BytesIO(buf.getvalue()))
|
| 460 |
+
if len(got.pages) != len(src.pages) + 1:
|
| 461 |
+
raise CoverError(f"the result has {len(got.pages)} pages, expected {len(src.pages) + 1}")
|
| 462 |
+
if bare_doi(paper["doi"]) not in re.sub(r"\s+", "", got.pages[0].extract_text() or ""):
|
| 463 |
+
raise CoverError("page 1 of the result doesn't read as the cover (its DOI isn't on it)")
|
| 464 |
+
for i, page in enumerate(src.pages):
|
| 465 |
+
if _content_digest(got.pages[i + 1]) != _content_digest(page):
|
| 466 |
+
raise CoverError(f"page {i + 2} of the result doesn't match page {i + 1} of {source.name}")
|
| 467 |
+
if hashlib.sha256(source.read_bytes()).hexdigest() != src_sha:
|
| 468 |
+
raise CoverError(f"{source.name} changed while its cover was being made")
|
| 469 |
+
|
| 470 |
+
out = (Path(out_dir) if out_dir else site.papers) / f"{key}-icsac.pdf"
|
| 471 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 472 |
+
_write_atomic(out, buf.getvalue())
|
| 473 |
+
print(f" wrote {out} ({out.stat().st_size} bytes, {len(got.pages)} pages, {writer.pdf_header})")
|
| 474 |
return out
|
| 475 |
|
| 476 |
|
| 477 |
+
def main(argv: list[str] | None = None) -> int:
|
| 478 |
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
| 479 |
ap.add_argument("--slug")
|
| 480 |
ap.add_argument("--all", action="store_true")
|
| 481 |
+
ap.add_argument("--fetch", action="store_true", help="Download a missing source PDF from Zenodo")
|
| 482 |
+
ap.add_argument("--accepted", help="acceptance date, YYYY-MM-DD (--slug only)")
|
| 483 |
+
ap.add_argument("--published", help="publication date, YYYY-MM-DD (--slug only)")
|
| 484 |
+
ap.add_argument("--out", type=Path, help="write <key>-icsac.pdf here instead of public/papers")
|
| 485 |
+
args = ap.parse_args(argv)
|
| 486 |
+
site_root = os.environ.get("ICSAC_WEBSITE_REPO")
|
| 487 |
+
if not site_root:
|
| 488 |
+
ap.error("set ICSAC_WEBSITE_REPO to the website checkout (the folder holding src/data/accepted.json)")
|
| 489 |
+
if not (args.slug or args.all) or (args.slug and args.all):
|
| 490 |
+
ap.error("specify --slug or --all")
|
| 491 |
+
if args.all and (args.accepted or args.published):
|
| 492 |
+
ap.error("--accepted and --published go with --slug")
|
| 493 |
+
site = Site(Path(site_root))
|
| 494 |
|
| 495 |
+
failed = 0
|
| 496 |
if args.all:
|
| 497 |
+
papers = site.load_papers()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
else:
|
| 499 |
+
try:
|
| 500 |
+
papers = [site.find(args.slug)]
|
| 501 |
+
except CoverError as exc:
|
| 502 |
+
print(f"cover not made: {exc}", file=sys.stderr)
|
| 503 |
+
return 1
|
| 504 |
+
for p in papers:
|
| 505 |
+
print(f"-> {p['slug']}")
|
| 506 |
+
if args.all and not pdf_key(p):
|
| 507 |
+
print(f" no PDF on the site for {p['slug']} (no record_id or sub_id): nothing to cover")
|
| 508 |
+
continue
|
| 509 |
+
try:
|
| 510 |
+
wrap_one(p, site, fetch=args.fetch, accepted=args.accepted, published=args.published,
|
| 511 |
+
out_dir=args.out)
|
| 512 |
+
except CoverError as exc:
|
| 513 |
+
failed += 1
|
| 514 |
+
print(f"cover not made: {p['slug']}: {exc}", file=sys.stderr)
|
| 515 |
+
if args.all:
|
| 516 |
+
print(f"{len(papers) - failed} of {len(papers)} handled, {failed} not made")
|
| 517 |
+
return 1 if failed else 0
|
| 518 |
|
| 519 |
|
| 520 |
if __name__ == "__main__":
|
| 521 |
+
sys.exit(main())
|