ICSAC commited on
Commit
b40e872
Β·
1 Parent(s): 0461f43

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 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) -> publications_pushed_at
 
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
- Side effects per successful upload:
23
- 1. Logo written to icsacinstitute.org/public/supporter-logos/<8hex>.<ext>
24
- 2. supporters.yaml updated (record keyed by stripe_sub id; logo_pending
25
- cleared, logo_path set, display_name + website_url filled).
26
- 3. `git commit && git push origin main` from the website repo.
 
 
 
 
 
 
 
 
 
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 _write_supporters(data: dict[str, Any]) -> None:
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
- body = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
 
 
 
 
 
 
240
  with tmp.open("w", encoding="utf-8") as f:
241
- f.write(header + body)
242
  tmp.replace(SUPPORTERS_YAML)
243
 
244
 
@@ -281,43 +301,161 @@ def _upsert_sponsor(
281
  existing["stripe_sub"] = stripe_sub
282
 
283
 
284
- def _git(*args: str) -> None:
285
- """Run a git command in the website repo. Raise 500 with a clean message
286
- on failure so the API caller gets a real error instead of a stack trace."""
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
- tail = (proc.stderr or proc.stdout or "").strip().splitlines()
297
- msg = " | ".join(tail[-3:]) if tail else "git command failed"
298
- raise HTTPException(500, f"git {args[0]} failed: {msg}")
299
-
300
-
301
- def _git_commit_push(logo_path_rel: str, display_name: str) -> None:
302
- """Commit supporters.yaml + the new logo, then push to origin/main."""
303
- # Pull first so the push can fast-forward β€” the website repo is the
304
- # source of truth and may have unrelated commits from elsewhere.
305
- _git("pull", "--rebase", "--autostash", "origin", "main")
306
- _git("add", "src/data/supporters.yaml", logo_path_rel)
307
- # If nothing changed (re-upload with identical bytes + same record),
308
- # commit will fail; treat that as success.
309
- status = subprocess.run(
310
- ["git", "diff", "--cached", "--quiet"],
311
- cwd=WEBSITE_REPO,
312
- check=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  )
314
- if status.returncode == 0:
315
- return # nothing staged; bail without commit/push
316
- msg = f"sponsors: add {display_name} logo"
317
- _git("-c", "user.email=info@icsacinstitute.org",
318
- "-c", "user.name=ICSAC",
319
- "commit", "-m", msg)
320
- _git("push", "origin", "main")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _git_commit_push(logo_path_rel, display_name)
 
 
 
 
 
 
 
438
 
439
  return {
440
  "ok": True,
 
441
  "display_name": display_name,
442
- "logo_path": "/" + logo_path_rel.removeprefix("public/"),
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 ICSAC-accepted paper with a 1-page ICSAC cover.
3
-
4
- Reads paper metadata from icsacinstitute.org/src/data/accepted.json,
5
- renders a Letter-trim cover via WeasyPrint, and prepends it to the
6
- author's PDF via pypdf. Output is written alongside the source PDF
7
- with an `-icsac.pdf` suffix.
8
-
9
- The author's manuscript is NEVER modified β€” the cover is a new first
10
- page only. The unwrapped PDF stays in place at <record_id>.pdf so
11
- Scholar / Zenodo / archival tooling can still grab the bare manuscript.
 
 
 
 
 
 
 
 
 
 
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 # download source from Zenodo if missing
 
 
 
 
 
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
- _SITE = os.environ.get("ICSAC_WEBSITE_REPO")
32
- if not _SITE:
33
- sys.exit("set ICSAC_WEBSITE_REPO to the website checkout (the folder holding src/data/accepted.json)")
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
- # License default per project policy
40
- DEFAULT_LICENSE = "CC-BY 4.0"
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
- def _accepted_us(iso: str) -> str:
44
- """2026-04-19 β†’ 04/19/2026 (US format used elsewhere on the site)."""
45
- if not iso:
46
- return ""
47
- y, m, d = iso.split("-")
48
- return f"{m}/{d}/{y}"
49
 
50
 
51
- def _authors_str(authors: list[str]) -> str:
52
- if not authors:
53
- return "Anonymous"
54
- if len(authors) <= 3:
55
- return ", ".join(authors)
56
- return ", ".join(authors[:2]) + f", et al. ({len(authors)} authors)"
 
 
57
 
 
 
 
58
 
59
- def _load_papers() -> list[dict]:
60
- d = json.loads(ACCEPTED.read_text())
61
- return d if isinstance(d, list) else d.get("papers", [])
 
 
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 _zenodo_fetch_pdf(record_id: str, dest: Path) -> None:
72
- """Download the primary PDF file from a Zenodo record."""
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
- _COVER_CSS = """
91
- @page { size: Letter; margin: 0.75in 0.85in 1.1in 0.85in; }
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
- .header img {
110
- width: 44pt;
111
- height: 44pt;
112
- }
113
 
114
- .header-text {
115
- display: flex;
116
- flex-direction: column;
117
- }
118
 
119
- .header-org {
120
- font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
121
- font-size: 9.5pt;
122
- letter-spacing: 1.2pt;
123
- text-transform: uppercase;
124
- color: #8B5E3C;
125
- font-weight: 600;
126
- line-height: 1.3;
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
- .title {
142
- font-size: 20pt;
143
- font-weight: 600;
144
- line-height: 1.22;
145
- margin: 0 0 10pt;
146
- color: #0A1929;
147
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- .authors {
150
- font-size: 11.5pt;
151
- color: #4B5563;
152
- font-style: italic;
153
- margin: 0 0 26pt;
154
- }
155
 
156
- .meta {
157
- display: grid;
158
- grid-template-columns: 115pt 1fr;
159
- gap: 7pt 16pt;
160
- font-size: 10pt;
161
- margin-bottom: 24pt;
162
- }
163
 
164
- .meta dt {
165
- font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
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
- .meta dd a {
181
- color: #1f1f1f;
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
- .venue strong {
195
- color: #8B5E3C;
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
- .record-label {
206
- letter-spacing: 0.6pt;
207
- text-transform: uppercase;
208
- font-size: 8pt;
209
- color: #8B5E3C;
210
- font-weight: 600;
211
- display: block;
212
- margin-bottom: 3pt;
213
- }
214
 
215
- .record a {
216
- color: #8B5E3C;
217
- text-decoration: none;
218
- word-break: break-all;
219
- }
220
 
221
- .note {
222
- position: fixed;
223
- bottom: 0.5in;
224
- left: 0.85in;
225
- right: 0.85in;
226
- padding-top: 8pt;
227
- border-top: 0.5pt solid #ccc;
228
- font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
229
- font-size: 7.5pt;
230
- line-height: 1.45;
231
- color: #888;
232
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  """
234
 
235
 
236
- def _render_cover(paper: dict) -> bytes:
237
- rid = paper.get("record_id") or ""
238
- slug = paper["slug"]
239
- title = paper["title"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="header">
256
- <img src="{logo_uri}" alt="" />
257
- <div class="header-text">
258
- <div class="header-org">Institute for Complexity Science and Advanced Computing</div>
259
- <div class="header-tag">Open editorial record &middot; No fees &middot; Author retains copyright</div>
 
260
  </div>
261
  </div>
262
 
263
- <div class="title-block">
264
- <div class="title">{_escape(title)}</div>
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>Accepted</dt><dd>{_escape(_accepted_us(accepted))}</dd>
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> &mdash; the Institute's open editorial record. The full record &mdash; AI panel reviews, Review Quality Control audit, and the curation team&rsquo;s verdict &mdash; is publicly available at the URL below.
277
  </div>
278
 
279
- <div class="record">
280
- <span class="record-label">Editorial record</span>
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
- def _escape(s: str) -> str:
292
- return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
- source = OUT_DIR / f"{rid}.pdf"
 
 
 
 
 
 
 
 
 
 
302
  if not source.exists():
303
- if fetch:
304
  print(f" source PDF missing, fetching from Zenodo: {source}")
305
- _zenodo_fetch_pdf(rid, source)
306
  else:
307
- raise SystemExit(f"source PDF missing: {source} (use --fetch to download)")
308
-
309
- print(f" rendering cover for {slug}…")
310
- cover_bytes = _render_cover(paper)
 
 
 
 
 
 
 
 
 
311
 
312
- out = OUT_DIR / f"{rid}-icsac.pdf"
313
  writer = pypdf.PdfWriter()
314
- writer.append(fileobj=io.BytesIO(cover_bytes))
315
- writer.append(fileobj=str(source))
316
- with open(out, "wb") as f:
317
- writer.write(f)
318
- print(f" wrote β†’ {out} ({out.stat().st_size} bytes)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- help="Download source PDF from Zenodo if missing")
328
- args = ap.parse_args()
329
-
330
- OUT_DIR.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
331
 
 
332
  if args.all:
333
- papers = _load_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
- ap.error("specify --slug or --all")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
 
 
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 = " &middot; ".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&rsquo;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())