Download publications.py from ICSAC-Institute/editorial-system: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/publications.py
- Command line
-
hf download hf://spaces/ICSAC-Institute/editorial-system/publications.py
-
curl -L -o publications.py https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/publications.py
16.2 kB
| """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/<slug>; entries with `record_id` also | |
| power the legacy /accepted/<record_id> 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/<key>.{md,html} files to <slug>.{md,html} | |
| so /publications/<slug> 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") | |