editorial-system / publications.py
ICSAC's picture
Release: acceptance and publication dates kept apart in the registry and on the cover page
43d2758
Raw History Blame Contribute Delete
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")