"""ICSAC publications registry — single source of truth for /publications. Maintains src/data/accepted.json on the icsacinstitute.org repo. Three writers populate the registry: - Zenodo community watcher (action.register_accepted_paper) — accepts in the icsac Zenodo community. - Submission intake DOI route (icsac-submission-intake/) — papers submitted via author-supplied DOI and accepted by the panel. - Submission intake PDF route post-publish (publish_watcher) — operator publishes the staged Zenodo draft and the watcher registers the now- live DOI. Every entry powers /publications/; entries with `record_id` also power the legacy /accepted/ share landings. Filename note: the on-disk file is still `accepted.json` for back-compat with TS imports that already reference it; semantically it's the publications registry. """ from __future__ import annotations import datetime import json import os import re import subprocess from typing import Any, Optional WEBSITE_REPO = os.environ.get("ICSAC_WEBSITE_REPO", "") REGISTRY_PATH = os.path.join(WEBSITE_REPO, "src/data/accepted.json") PUBLICATIONS_BASE_URL = "https://icsacinstitute.org/publications" VALID_SOURCES = {"zenodo-community", "submission-doi", "submission-pdf"} def make_slug(title: str, existing_slugs: Optional[set[str]] = None) -> str: """Slugify a title to a kebab-case URL fragment. Splits on first colon or em/en-dash so subtitles don't bloat the URL, then lowercases + reduces non-alphanumerics to single hyphens. Caps at 80 chars. On collision with `existing_slugs`, appends -2, -3, ... """ src = (title or "").strip() if not src: src = "paper" base = re.split(r"\s*[:—–]\s*", src, maxsplit=1)[0].strip() or src slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-") slug = slug[:80].rstrip("-") or "paper" if not existing_slugs: return slug candidate = slug n = 2 while candidate in existing_slugs: candidate = f"{slug}-{n}" n += 1 return candidate def publications_url(slug: str) -> str: return f"{PUBLICATIONS_BASE_URL}/{slug}" def _load_registry() -> list[dict]: if not os.path.exists(REGISTRY_PATH): raise FileNotFoundError(f"Registry missing: {REGISTRY_PATH}") with open(REGISTRY_PATH) as f: return json.load(f) def _save_registry(registry: list[dict]) -> None: with open(REGISTRY_PATH, "w") as f: json.dump(registry, f, indent=2, ensure_ascii=False) f.write("\n") # Optional registry fields the website schema knows. They ride through an # upsert untouched (audit 2026-09-28 item 10: they were dropped, sub_id and the # licence with them, so a Crossref-path landing page had no PDF link). _OPTIONAL_KEYS = ( "published_date", "license_url", "keywords", "canonical_url", "ssrn_id", "ssrn_url", "ssrn_doi", "promotion_opt_out", "promotion_exclusions", "persistence_opt_out", "code_data_url", "preprint_doi", ) KEYWORD_LIMIT = 12 KEYWORD_MAX_CHARS = 80 def clean_keywords(raw: Any) -> list[str]: """Keywords as the paper page shows them: a list or a comma/semicolon string in; each item split on commas and semicolons, trimmed, trailing full stops dropped, case-insensitive duplicates and over-long items removed, at most KEYWORD_LIMIT kept in the author's order.""" if raw is None: return [] items = raw if isinstance(raw, (list, tuple)) else [raw] out: list[str] = [] seen: set[str] = set() for item in items: for part in re.split(r"[,;]+", str(item)): k = re.sub(r"\s+", " ", part).strip().rstrip(".").strip() if not k or len(k) > KEYWORD_MAX_CHARS or k.casefold() in seen: continue seen.add(k.casefold()) out.append(k) if len(out) >= KEYWORD_LIMIT: return out return out def code_data_url(submission: dict) -> Optional[str]: """The author's public code and data link from the submission form, if any. The paper page prints it as hosted by the author; ICSAC never hosts it.""" url = ((submission.get("code_data") or {}).get("url") or "").strip() return url or None def _match_existing(registry: list[dict], proto: dict) -> Optional[int]: """Find an existing registry entry by record_id, sub_id, then doi. Returns its index or None.""" rid = proto.get("record_id") sid = proto.get("sub_id") doi = proto.get("doi") for i, e in enumerate(registry): if rid and e.get("record_id") == rid: return i if sid and e.get("sub_id") == sid: return i if doi and e.get("doi") == doi: return i return None def find_entry(proto: dict) -> Optional[dict]: """The registry entry an upsert of `proto` would update, or None (also when the registry is not configured or cannot be read).""" if not WEBSITE_REPO: return None try: registry = _load_registry() except (OSError, ValueError): return None i = _match_existing(registry, proto) return dict(registry[i]) if i is not None else None # ── The publish gate (2026-09-29) ───────────────────────────────────────────── # The curator's rule: nothing publishes anywhere without his approval; everything # else is drafted and ready to go. The writers below (registry, public review, # commit + push) refuse unless the caller carries the approval: # * register-doi.sh --live passes approved=True; its typed confirmation IS the approval; # * a hand-run of a legacy path may set ICSAC_PUBLISH_APPROVED=1 in its own environment. # No service unit, timer or cron sets ICSAC_PUBLISH_APPROVED. Anything else is HELD: # the entry is saved under READY_DIR and nothing is written to the website. READY_DIR = os.path.expanduser("~/icsac-submissions/ready-to-publish") def publish_approved(approved: bool = False) -> bool: return bool(approved) or os.environ.get("ICSAC_PUBLISH_APPROVED") == "1" def hold_for_approval(proto: dict, reason: str = "") -> str: """Save the entry as ready to publish; return the file written. Writes nothing to the site.""" os.makedirs(READY_DIR, exist_ok=True) key = proto.get("sub_id") or proto.get("record_id") or proto.get("doi") or "entry" key = re.sub(r"[^A-Za-z0-9._-]+", "_", str(key)) path = os.path.join(READY_DIR, f"{key}.json") record = {"held_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "reason": reason, "proto": proto} tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(record, f, indent=2, ensure_ascii=False) f.write("\n") os.replace(tmp, path) print(f" publications: HELD for the curator's approval (nothing published) -> {path}") return path class PublishHeld(Exception): """Raised by callers that must stop because publishing was not approved.""" def __init__(self, ready_path: str): self.ready_path = ready_path super().__init__(f"held for the curator's approval: {ready_path}") def upsert_entry(proto: dict, *, approved: bool = False) -> dict: """Insert or update a publications entry. Returns the final entry. `proto` must carry: title, authors (list[str]), doi, source. Optional: abstract, source_ref, record_id, slug (auto-derived from title if absent), and two dates, YYYY-MM-DD: accepted_date the day the paper was accepted: the page's "Accepted" row, the journal's order and the volume. Defaults to the existing entry's, else today. published_date the day it was published online, when that is not the day of acceptance (the Crossref route registers the DOI after the decision). The publication date Google Scholar, the JSON-LD and the feed read; without it they read accepted_date. Absent, the existing entry's is kept. Never before accepted_date (ValueError, nothing written). Existing entries (matched by record_id or doi) are updated in place; the existing slug is preserved for URL stability. New entries get a fresh slug, deduped against the current registry. Caller is responsible for staging any ancillary files (public-review HTML, etc.) and then calling commit_and_push(). Returns an empty dict when ICSAC_WEBSITE_REPO is not configured (the Zenodo accept itself still proceeds; the registry publish is skipped). """ if not publish_approved(approved): hold_for_approval(proto, "registry entry") return {} if not WEBSITE_REPO: print(" Registry publish skipped: ICSAC_WEBSITE_REPO not configured") return {} if proto.get("source") not in VALID_SOURCES: raise ValueError( f"invalid source {proto.get('source')!r}; want one of {sorted(VALID_SOURCES)}" ) if not proto.get("title"): raise ValueError("proto.title is required") if not proto.get("doi"): raise ValueError("proto.doi is required") if not proto.get("authors"): raise ValueError("proto.authors must be a non-empty list") registry = _load_registry() existing_idx = _match_existing(registry, proto) # Start from the prior record so its optional fields survive an update. final: dict[str, Any] = dict(registry[existing_idx]) if existing_idx is not None else {} if existing_idx is not None: prior = registry[existing_idx] final["slug"] = prior.get("slug") or make_slug( proto["title"], {e.get("slug") for e in registry if e is not prior and e.get("slug")}, ) final["accepted_date"] = ( proto.get("accepted_date") or prior.get("accepted_date") or datetime.date.today().isoformat() ) else: existing_slugs = {e.get("slug") for e in registry if e.get("slug")} final["slug"] = proto.get("slug") or make_slug(proto["title"], existing_slugs) final["accepted_date"] = ( proto.get("accepted_date") or datetime.date.today().isoformat() ) if proto.get("record_id"): final["record_id"] = str(proto["record_id"]) if proto.get("sub_id"): final["sub_id"] = str(proto["sub_id"]) final["title"] = proto["title"] final["authors"] = list(proto["authors"]) final["doi"] = proto["doi"] final["source"] = proto["source"] if proto.get("source_ref"): final["source_ref"] = proto["source_ref"] if proto.get("abstract"): final["abstract"] = proto["abstract"] for key in _OPTIONAL_KEYS: if key in proto and proto[key] not in (None, "", [], {}): final[key] = proto[key] # The website's schema refuses this, and a refused registry fails every build. if final.get("published_date") and final["published_date"] < final["accepted_date"]: raise ValueError( f"published_date {final['published_date']} is before accepted_date " f"{final['accepted_date']} for {proto['doi']}; registry not written" ) # Re-key in canonical insert order so the JSON stays diff-friendly; any # field not named here is kept at the end rather than dropped. The two # dates sit together (published_date keeps its first position below). ordered_keys = [ "slug", "record_id", "sub_id", "title", "authors", "doi", "accepted_date", "published_date", "source", "source_ref", "abstract", *_OPTIONAL_KEYS, ] canonical = {k: final[k] for k in ordered_keys if k in final} for k, v in final.items(): canonical.setdefault(k, v) if existing_idx is not None: registry[existing_idx] = canonical else: registry.append(canonical) _save_registry(registry) return canonical def stage_public_review_for_slug( review_key: str, slug: str, reviews_dir: str, *, approved: bool = False, ) -> tuple[Optional[str], Optional[str]]: """Redact the panel review + RQC keyed by `review_key`, then rename the generated public-reviews/.{md,html} files to .{md,html} so /publications/ can find them. `review_key` is the prefix redaction.publish_public_review searches for under reviews_dir — record_id for Zenodo-watcher-path papers, sub_id (ICSAC-SUB-NNNNN) for intake-path papers. Returns (review_md_path, rqc_md_path) — either may be None if no matching review was found. RedactionLeak from the underlying redaction bubbles up; callers gate it the same way action.accept_request does. Returns (None, None) when ICSAC_WEBSITE_REPO is not configured. """ if not publish_approved(approved): print(f" publications: public review for {slug} HELD for the curator's approval") return (None, None) if not WEBSITE_REPO: print(" Public-review stage skipped: ICSAC_WEBSITE_REPO not configured") return (None, None) import redaction # editorial system module review_md_orig = redaction.publish_public_review( review_key, reviews_dir, WEBSITE_REPO, ) rqc_md_orig = redaction.publish_public_rqc( review_key, reviews_dir, WEBSITE_REPO, ) out_dir = os.path.join(WEBSITE_REPO, "src", "data", "public-reviews") def _rename_pair(orig_md: Optional[str], src_base: str, dst_base: str) -> Optional[str]: if not orig_md or src_base == dst_base: return orig_md final_md: Optional[str] = None for ext in (".md", ".html"): src = os.path.join(out_dir, f"{src_base}{ext}") dst = os.path.join(out_dir, f"{dst_base}{ext}") if not os.path.exists(src): continue if os.path.exists(dst): os.remove(dst) os.rename(src, dst) if ext == ".md": final_md = dst return final_md final_review = _rename_pair(review_md_orig, review_key, slug) final_rqc = _rename_pair( rqc_md_orig, f"{review_key}_review_quality_control", f"{slug}_review_quality_control", ) return final_review, final_rqc def commit_and_push(message: str, extra_paths: Optional[list[str]] = None, *, approved: bool = False) -> None: """Stage accepted.json (+ any extras), commit, pull --rebase, push. No-op when the working tree is clean. Best-effort `git pull --rebase`; push failures raise so callers can surface a /pain signal. No-op when ICSAC_WEBSITE_REPO is not configured, and when the caller carries no approval (the publish gate above). """ if not publish_approved(approved): print(f" publications: push HELD for the curator's approval ({message[:80]})") return if not WEBSITE_REPO: return def run(*cmd, check=True): return subprocess.run( cmd, cwd=WEBSITE_REPO, capture_output=True, text=True, check=check ) run("git", "add", "src/data/accepted.json") for p in extra_paths or []: if not p: continue if os.path.isabs(p): rel = os.path.relpath(p, WEBSITE_REPO) else: rel = p full = os.path.join(WEBSITE_REPO, rel) if os.path.exists(full): run("git", "add", rel) # If the path is a markdown file, also stage the sibling .html # (redaction writes pairs). if rel.endswith(".md"): html_rel = rel[:-3] + ".html" if os.path.exists(os.path.join(WEBSITE_REPO, html_rel)): run("git", "add", html_rel) status = run("git", "status", "--porcelain").stdout if not status.strip(): return run("git", "commit", "-m", message) try: run("git", "pull", "--rebase", "--autostash", "origin", "main") except subprocess.CalledProcessError as e: print(f" git pull --rebase warning: {e.stderr.strip()}") run("git", "push", "origin", "HEAD:main")