ICSAC commited on
Commit
43d2758
Β·
1 Parent(s): a14e17c

Release: acceptance and publication dates kept apart in the registry and on the cover page

Browse files
crossref_deposit.py CHANGED
@@ -576,6 +576,18 @@ def landing_url_for(submission: dict, sub_dir: Path) -> str:
576
  return publications.publications_url(publications.make_slug(submission.get("title", "")))
577
 
578
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  def stage(sub_dir: Path, submission: Optional[dict] = None, *,
580
  doi: Optional[str] = None, out_dir: Optional[Path] = None,
581
  content_type: Optional[str] = None,
@@ -596,13 +608,7 @@ def stage(sub_dir: Path, submission: Optional[dict] = None, *,
596
  landing = landing_url_for(submission, sub_dir)
597
  pdf_url = _cfg("CROSSREF_PDF_URL", "https://icsacinstitute.org/papers/{sub_id}.pdf")
598
  pdf_url = pdf_url.format(sub_id=sub_id) if pdf_url else None
599
- acc = None
600
- try:
601
- st = json.loads((sub_dir / "state.json").read_text())
602
- if st.get("decision") == "accept" and st.get("completed_at"):
603
- acc = _dt.date.fromisoformat(st["completed_at"][:10])
604
- except Exception:
605
- acc = None
606
  xml_bytes = build_deposit_xml(submission, doi=doi, landing_url=landing, pdf_url=pdf_url,
607
  citations=_load_citations(sub_id), content_type=content_type,
608
  acceptance_date=acc)
@@ -998,6 +1004,37 @@ def _deposited_dates(xml_path: Path) -> tuple[Optional[_dt.date], Optional[_dt.d
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."""
@@ -1074,6 +1111,9 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
1074
  pre = preprint_doi(submission)
1075
  if pre:
1076
  proto["preprint_doi"] = pre # the paper page links the preprint it was submitted from
 
 
 
1077
  # register --live runs only after the curator typed the confirmation: approved.
1078
  entry = publications.upsert_entry(proto, approved=True)
1079
  if not entry:
 
576
  return publications.publications_url(publications.make_slug(submission.get("title", "")))
577
 
578
 
579
+ def _decision_date(sub_dir: Path) -> Optional[_dt.date]:
580
+ """The acceptance day: the UTC date of the accept decision's stamp
581
+ (state.json completed_at), or None when the paper has no accept stamp."""
582
+ try:
583
+ st = json.loads((Path(sub_dir) / "state.json").read_text())
584
+ if st.get("decision") == "accept" and st.get("completed_at"):
585
+ return _dt.date.fromisoformat(st["completed_at"][:10])
586
+ except Exception:
587
+ pass
588
+ return None
589
+
590
+
591
  def stage(sub_dir: Path, submission: Optional[dict] = None, *,
592
  doi: Optional[str] = None, out_dir: Optional[Path] = None,
593
  content_type: Optional[str] = None,
 
608
  landing = landing_url_for(submission, sub_dir)
609
  pdf_url = _cfg("CROSSREF_PDF_URL", "https://icsacinstitute.org/papers/{sub_id}.pdf")
610
  pdf_url = pdf_url.format(sub_id=sub_id) if pdf_url else None
611
+ acc = _decision_date(sub_dir)
 
 
 
 
 
 
612
  xml_bytes = build_deposit_xml(submission, doi=doi, landing_url=landing, pdf_url=pdf_url,
613
  citations=_load_citations(sub_id), content_type=content_type,
614
  acceptance_date=acc)
 
1004
  return acc, pub
1005
 
1006
 
1007
+ def _registry_dates(sub_dir: Path, proto: dict, *, log) -> dict:
1008
+ """accepted_date + published_date for the registry entry, so the page shows
1009
+ the dates Crossref holds: each is read back from the deposited XML. A date
1010
+ the deposit lacks keeps the existing entry's (a re-run changes nothing),
1011
+ else falls back to the accept decision's stamp (accepted) or today
1012
+ (published). The log names where each date came from."""
1013
+ import publications
1014
+ dep_acc, dep_pub = _deposited_dates(sub_dir / "crossref" / "deposit.xml")
1015
+ prior = publications.find_entry(proto) or {}
1016
+ out: dict = {}
1017
+ said: list[str] = []
1018
+ for key, label, deposited in (("accepted_date", "accepted", dep_acc),
1019
+ ("published_date", "published", dep_pub)):
1020
+ if deposited:
1021
+ out[key] = deposited.isoformat()
1022
+ said.append(f"{label} {out[key]} (from the deposit)")
1023
+ elif prior.get(key):
1024
+ # Left out of the proto: the upsert keeps the entry's own value.
1025
+ said.append(f"{label} {prior[key]} (NOT in the deposit; the existing entry's, kept)")
1026
+ elif key == "accepted_date" and _decision_date(sub_dir):
1027
+ out[key] = _decision_date(sub_dir).isoformat()
1028
+ said.append(f"{label} {out[key]} (NOT in the deposit; the accept decision's stamp)")
1029
+ else:
1030
+ out[key] = _dt.date.today().isoformat()
1031
+ said.append(f"{label} {out[key]} (NOT in the deposit; today)")
1032
+ if out.get("published_date") and out.get("accepted_date") and out["published_date"] < out["accepted_date"]:
1033
+ said.append(f"published {out.pop('published_date')} DROPPED: before acceptance")
1034
+ log(f" crossref: registry dates: {'; '.join(said)}")
1035
+ return out
1036
+
1037
+
1038
  def _make_cover(sub_dir: Path, slug: str, *, log) -> dict:
1039
  """Run tools/wrap-paper-pdf.py on the PDF just copied to the website tree.
1040
  Returns {"file", "made_at", "path"} or {"error", "at"}; never raises."""
 
1111
  pre = preprint_doi(submission)
1112
  if pre:
1113
  proto["preprint_doi"] = pre # the paper page links the preprint it was submitted from
1114
+ # Accepted and published as deposited; upsert_entry would otherwise date
1115
+ # acceptance to registration day.
1116
+ proto.update(_registry_dates(sub_dir, proto, log=log))
1117
  # register --live runs only after the curator typed the confirmation: approved.
1118
  entry = publications.upsert_entry(proto, approved=True)
1119
  if not entry:
intake/scripts/test_registry_dates.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Checks the two dates of a registry entry: accepted_date (the day of
3
+ acceptance) and published_date (the day of online publication). register
4
+ --live writes both from the deposited Crossref XML, so the page shows the
5
+ dates Crossref holds; a date missing there falls back to the accept
6
+ decision's stamp (accepted) or today (published), and the log says so; an
7
+ existing entry keeps its dates on a re-run.
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_registry_dates.py
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import json
17
+ import shutil
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
+ failures: list[str] = []
27
+
28
+
29
+ def check(cond: bool, name: str) -> None:
30
+ print((" ok " if cond else " FAIL ") + name)
31
+ if not cond:
32
+ failures.append(name)
33
+
34
+
35
+ def deposit_xml(published: str | None, accepted: str | None) -> str:
36
+ """A minimal deposit: the issue's year-only date, then the article's dates."""
37
+ def d(tag: str, iso: str | None, attrs: str = "") -> str:
38
+ if not iso:
39
+ return ""
40
+ y, m, dd = iso.split("-")
41
+ return f"<{tag}{attrs}><month>{m}</month><day>{dd}</day><year>{y}</year></{tag}>"
42
+ online = ' media_type="online"'
43
+ return ('<?xml version="1.0"?>\n<doi_batch xmlns="http://www.crossref.org/schema/5.4.0"><body><journal>'
44
+ '<journal_issue><publication_date media_type="online"><year>2026</year></publication_date></journal_issue>'
45
+ f'<journal_article>{d("publication_date", published, online)}'
46
+ f'{d("acceptance_date", accepted)}</journal_article></journal></body></doi_batch>')
47
+
48
+
49
+ tmp = Path(tempfile.mkdtemp(prefix="registry-dates-"))
50
+ try:
51
+ import publications as pub
52
+ import crossref_deposit as cd
53
+ from intake import author_approval
54
+
55
+ site = tmp / "site"; (site / "src" / "data").mkdir(parents=True)
56
+ reg = site / "src" / "data" / "accepted.json"
57
+ pub.WEBSITE_REPO = str(site); pub.REGISTRY_PATH = str(reg)
58
+ pub.READY_DIR = str(tmp / "ready-to-publish") # held entries stay inside the temp dir
59
+
60
+ def entries() -> list[dict]:
61
+ return json.loads(reg.read_text())
62
+
63
+ # ── 1. the registry keeps both dates ─────────────────────────────────────
64
+ print("1. registry")
65
+ reg.write_text("[]")
66
+ base = {"title": "T", "authors": ["A. Author"], "doi": "10.5555/icsac.2026.980", "source": "submission-pdf"}
67
+ e = pub.upsert_entry(dict(base, accepted_date="2026-03-02", published_date="2026-03-03"), approved=True)
68
+ check((e.get("accepted_date"), e.get("published_date")) == ("2026-03-02", "2026-03-03"), "upsert writes both dates")
69
+ keys = list(e)
70
+ check(keys.index("published_date") == keys.index("accepted_date") + 1, "published_date sits right after accepted_date")
71
+ e = pub.upsert_entry(dict(base), approved=True)
72
+ check((e.get("accepted_date"), e.get("published_date")) == ("2026-03-02", "2026-03-03"),
73
+ "a later upsert without dates keeps both")
74
+ before = reg.read_text()
75
+ try:
76
+ pub.upsert_entry(dict(base, published_date="2026-03-01"), approved=True)
77
+ refused = False
78
+ except ValueError:
79
+ refused = True
80
+ check(refused and reg.read_text() == before, "published before accepted: ValueError, registry unchanged")
81
+ e = pub.upsert_entry(dict(base, doi="10.5555/icsac.2026.981", accepted_date="2026-03-02"), approved=True)
82
+ check("published_date" not in e, "an entry given no published_date gets none (the page then reads accepted_date)")
83
+ check(pub.find_entry({"doi": "10.5555/icsac.2026.980"}).get("published_date") == "2026-03-03"
84
+ and pub.find_entry({"doi": "10.5555/icsac.2026.999"}) is None, "find_entry: the entry an upsert would update, else None")
85
+
86
+ # ── 2. the acceptance day is the decision stamp's UTC date ───────────────
87
+ print("2. decision stamp")
88
+ sd = tmp / "stamp"; sd.mkdir()
89
+ (sd / "state.json").write_text(json.dumps({"decision": "accept", "completed_at": "2026-02-10T23:30:00Z"}))
90
+ check(str(cd._decision_date(sd)) == "2026-02-10", "an accept stamp gives its UTC date")
91
+ (sd / "state.json").write_text(json.dumps({"decision": "revise", "completed_at": "2026-02-10T23:30:00Z"}))
92
+ check(cd._decision_date(sd) is None, "a stamp on another decision gives none")
93
+ (sd / "state.json").unlink()
94
+ check(cd._decision_date(sd) is None, "no state.json gives none")
95
+
96
+ # ── 3. register --live writes the dates from the deposit ─────────────────
97
+ print("3. register --live")
98
+ sys.modules["stats"] = types.SimpleNamespace(
99
+ compute_stats=lambda *a, **k: (_ for _ in ()).throw(RuntimeError("stats stubbed")))
100
+ pub.stage_public_review_for_slug = lambda *a, **k: (None, None)
101
+ pub.commit_and_push = lambda *a, **k: None
102
+ cd.stage = lambda *a, **k: None
103
+ cd.validate_xml = lambda b: None
104
+ cd.deposit = lambda *a, **k: "batch-1"
105
+ cd.poll_result = lambda *a, **k: {"status": "completed", "success": 1, "failure": 0}
106
+ cd._ping = lambda *a, **k: None
107
+ cd._publish_zenodo_archive = lambda *a, **k: None
108
+ cd._notify_author = lambda *a, **k: True
109
+ author_approval.gate = lambda *a, **k: (True, "approved (stub)")
110
+
111
+ def make_sub(name: str, doi: str, xml: str, state: dict) -> Path:
112
+ sub = tmp / name
113
+ (sub / "crossref").mkdir(parents=True)
114
+ (sub / "submission.json").write_text(json.dumps({
115
+ "sub_id": name, "title": f"Article {name}", "abstract": "A", "license": "cc-by-4.0",
116
+ "creators": [{"name": "Lovelace, Ada"}], "form": {"name": "Ada Lovelace"}}))
117
+ (sub / "state.json").write_text(json.dumps(state))
118
+ (sub / "crossref" / "deposit.xml").write_text(xml)
119
+ (sub / "crossref" / "staged.json").write_text(json.dumps({"doi": doi, "registered": False}))
120
+ return sub
121
+
122
+ def entry_for(sub_id: str) -> dict:
123
+ return next(x for x in entries() if x.get("sub_id") == sub_id)
124
+
125
+ reg.write_text("[]")
126
+ accept = {"decision": "accept", "state": "completed", "completed_at": "2026-02-10T23:30:00Z"}
127
+ logs: list[str] = []
128
+ sub = make_sub("ICSAC-SUB-09980", "10.5555/icsac.2026.982", deposit_xml("2026-03-03", "2026-03-02"), accept)
129
+ cd.register(sub, live=True, log=logs.append)
130
+ e = entry_for("ICSAC-SUB-09980")
131
+ check((e.get("accepted_date"), e.get("published_date")) == ("2026-03-02", "2026-03-03"),
132
+ "both dates are the deposit's (not the stamp's, not today)")
133
+ said = [m for m in logs if "registry dates:" in m]
134
+ check(said and "accepted 2026-03-02 (from the deposit)" in said[-1]
135
+ and "published 2026-03-03 (from the deposit)" in said[-1], "the log names the deposit as the source of each")
136
+
137
+ # ── 4. a date missing from the deposit falls back, and the log says so ───
138
+ print("4. fallback")
139
+ today = datetime.date.today().isoformat()
140
+ logs.clear()
141
+ sub = make_sub("ICSAC-SUB-09981", "10.5555/icsac.2026.983", deposit_xml(None, None), accept)
142
+ cd.register(sub, live=True, log=logs.append)
143
+ e = entry_for("ICSAC-SUB-09981")
144
+ check(e.get("accepted_date") == "2026-02-10", "accepted falls back to the decision stamp's date")
145
+ check(e.get("published_date") in (today, datetime.date.today().isoformat()), "published falls back to today")
146
+ said = [m for m in logs if "registry dates:" in m]
147
+ check(said and "accepted 2026-02-10 (NOT in the deposit; the accept decision's stamp)" in said[-1]
148
+ and "(NOT in the deposit; today)" in said[-1], "the log says which date fell back, and to what")
149
+ logs.clear()
150
+ sub = make_sub("ICSAC-SUB-09982", "10.5555/icsac.2026.984", deposit_xml(None, None),
151
+ {"decision": "accept", "state": "completed"})
152
+ cd.register(sub, live=True, log=logs.append)
153
+ said = [m for m in logs if "registry dates:" in m]
154
+ check(said and f"accepted {entry_for('ICSAC-SUB-09982').get('accepted_date')} (NOT in the deposit; today)" in said[-1],
155
+ "no stamp either: accepted is today, and the log says so")
156
+ logs.clear()
157
+ sub = make_sub("ICSAC-SUB-09983", "10.5555/icsac.2026.985", deposit_xml("2026-03-01", "2026-03-02"), accept)
158
+ cd.register(sub, live=True, log=logs.append)
159
+ e = entry_for("ICSAC-SUB-09983")
160
+ said = [m for m in logs if "registry dates:" in m]
161
+ check(e.get("accepted_date") == "2026-03-02" and "published_date" not in e
162
+ and said and "published 2026-03-01 DROPPED: before acceptance" in said[-1],
163
+ "a deposited publication before acceptance is dropped, loudly, and the page still publishes")
164
+
165
+ # ── 5. a re-run keeps an existing entry's dates ──────────────────────────
166
+ print("5. re-run")
167
+
168
+ class LaterDay(datetime.date):
169
+ @classmethod
170
+ def today(cls):
171
+ return datetime.date(2026, 6, 1)
172
+
173
+ def rerun(sub: Path) -> None:
174
+ """A crash between the page push and its checkpoint: register runs the landing step again."""
175
+ sp = sub / "crossref" / "staged.json"
176
+ st = json.loads(sp.read_text())
177
+ st.get("checkpoints", {}).pop("publications_pushed_at", None)
178
+ sp.write_text(json.dumps(st))
179
+ real_dt = cd._dt
180
+ cd._dt = types.SimpleNamespace(date=LaterDay, datetime=datetime.datetime, timezone=datetime.timezone)
181
+ try:
182
+ cd.register(sub, live=True, log=logs.append)
183
+ finally:
184
+ cd._dt = real_dt
185
+
186
+ first = entry_for("ICSAC-SUB-09981")
187
+ logs.clear()
188
+ rerun(tmp / "ICSAC-SUB-09981")
189
+ again = entry_for("ICSAC-SUB-09981")
190
+ check(any("registry dates:" in m for m in logs), "the re-run reached the landing step")
191
+ check((again.get("accepted_date"), again.get("published_date"))
192
+ == (first.get("accepted_date"), first.get("published_date")),
193
+ "fallback dates survive a re-run on a later day")
194
+ said = [m for m in logs if "registry dates:" in m]
195
+ check(said and "the existing entry's, kept" in said[-1], "the log says the existing dates were kept")
196
+ logs.clear()
197
+ rerun(tmp / "ICSAC-SUB-09980")
198
+ again = entry_for("ICSAC-SUB-09980")
199
+ check((again.get("accepted_date"), again.get("published_date")) == ("2026-03-02", "2026-03-03"),
200
+ "deposited dates are the same on a re-run")
201
+ check(len([x for x in entries() if x.get("sub_id") == "ICSAC-SUB-09980"]) == 1, "a re-run updates, never duplicates")
202
+ finally:
203
+ shutil.rmtree(tmp, ignore_errors=True)
204
+
205
+ print()
206
+ if failures:
207
+ print(f"FAILED: {len(failures)}")
208
+ for f in failures:
209
+ print(" - " + f)
210
+ sys.exit(1)
211
+ print("ALL GREEN")
publications.py CHANGED
@@ -80,7 +80,7 @@ def _save_registry(registry: list[dict]) -> None:
80
  # upsert untouched (audit 2026-09-28 item 10: they were dropped, sub_id and the
81
  # licence with them, so a Crossref-path landing page had no PDF link).
82
  _OPTIONAL_KEYS = (
83
- "license_url", "keywords", "canonical_url", "ssrn_id", "ssrn_url", "ssrn_doi",
84
  "promotion_opt_out", "promotion_exclusions", "persistence_opt_out",
85
  "code_data_url", "preprint_doi",
86
  )
@@ -134,6 +134,19 @@ def _match_existing(registry: list[dict], proto: dict) -> Optional[int]:
134
  return None
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  # ── The publish gate (2026-09-29) ─────────────────────────────────────────────
138
  # The curator's rule: nothing publishes anywhere without his approval; everything
139
  # else is drafted and ready to go. The writers below (registry, public review,
@@ -178,8 +191,18 @@ def upsert_entry(proto: dict, *, approved: bool = False) -> dict:
178
  """Insert or update a publications entry. Returns the final entry.
179
 
180
  `proto` must carry: title, authors (list[str]), doi, source. Optional:
181
- abstract, source_ref, record_id, accepted_date (defaults to today),
182
- slug (auto-derived from title if absent).
 
 
 
 
 
 
 
 
 
 
183
 
184
  Existing entries (matched by record_id or doi) are updated in place;
185
  the existing slug is preserved for URL stability. New entries get a
@@ -246,12 +269,20 @@ def upsert_entry(proto: dict, *, approved: bool = False) -> dict:
246
  for key in _OPTIONAL_KEYS:
247
  if key in proto and proto[key] not in (None, "", [], {}):
248
  final[key] = proto[key]
 
 
 
 
 
 
249
 
250
  # Re-key in canonical insert order so the JSON stays diff-friendly; any
251
- # field not named here is kept at the end rather than dropped.
 
252
  ordered_keys = [
253
  "slug", "record_id", "sub_id", "title", "authors", "doi",
254
- "accepted_date", "source", "source_ref", "abstract", *_OPTIONAL_KEYS,
 
255
  ]
256
  canonical = {k: final[k] for k in ordered_keys if k in final}
257
  for k, v in final.items():
 
80
  # upsert untouched (audit 2026-09-28 item 10: they were dropped, sub_id and the
81
  # licence with them, so a Crossref-path landing page had no PDF link).
82
  _OPTIONAL_KEYS = (
83
+ "published_date", "license_url", "keywords", "canonical_url", "ssrn_id", "ssrn_url", "ssrn_doi",
84
  "promotion_opt_out", "promotion_exclusions", "persistence_opt_out",
85
  "code_data_url", "preprint_doi",
86
  )
 
134
  return None
135
 
136
 
137
+ def find_entry(proto: dict) -> Optional[dict]:
138
+ """The registry entry an upsert of `proto` would update, or None (also when
139
+ the registry is not configured or cannot be read)."""
140
+ if not WEBSITE_REPO:
141
+ return None
142
+ try:
143
+ registry = _load_registry()
144
+ except (OSError, ValueError):
145
+ return None
146
+ i = _match_existing(registry, proto)
147
+ return dict(registry[i]) if i is not None else None
148
+
149
+
150
  # ── The publish gate (2026-09-29) ─────────────────────────────────────────────
151
  # The curator's rule: nothing publishes anywhere without his approval; everything
152
  # else is drafted and ready to go. The writers below (registry, public review,
 
191
  """Insert or update a publications entry. Returns the final entry.
192
 
193
  `proto` must carry: title, authors (list[str]), doi, source. Optional:
194
+ abstract, source_ref, record_id, slug (auto-derived from title if absent),
195
+ and two dates, YYYY-MM-DD:
196
+
197
+ accepted_date the day the paper was accepted: the page's "Accepted"
198
+ row, the journal's order and the volume. Defaults to
199
+ the existing entry's, else today.
200
+ published_date the day it was published online, when that is not the
201
+ day of acceptance (the Crossref route registers the DOI
202
+ after the decision). The publication date Google
203
+ Scholar, the JSON-LD and the feed read; without it they
204
+ read accepted_date. Absent, the existing entry's is kept.
205
+ Never before accepted_date (ValueError, nothing written).
206
 
207
  Existing entries (matched by record_id or doi) are updated in place;
208
  the existing slug is preserved for URL stability. New entries get a
 
269
  for key in _OPTIONAL_KEYS:
270
  if key in proto and proto[key] not in (None, "", [], {}):
271
  final[key] = proto[key]
272
+ # The website's schema refuses this, and a refused registry fails every build.
273
+ if final.get("published_date") and final["published_date"] < final["accepted_date"]:
274
+ raise ValueError(
275
+ f"published_date {final['published_date']} is before accepted_date "
276
+ f"{final['accepted_date']} for {proto['doi']}; registry not written"
277
+ )
278
 
279
  # Re-key in canonical insert order so the JSON stays diff-friendly; any
280
+ # field not named here is kept at the end rather than dropped. The two
281
+ # dates sit together (published_date keeps its first position below).
282
  ordered_keys = [
283
  "slug", "record_id", "sub_id", "title", "authors", "doi",
284
+ "accepted_date", "published_date", "source", "source_ref", "abstract",
285
+ *_OPTIONAL_KEYS,
286
  ]
287
  canonical = {k: final[k] for k in ordered_keys if k in final}
288
  for k, v in final.items():
tools/wrap-paper-pdf.py CHANGED
@@ -163,12 +163,18 @@ 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'])}"
@@ -320,7 +326,7 @@ def _page_size(page) -> str:
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 "",
 
163
  return "https://doi.org/" + re.sub(r"^doi:", "", doi, flags=re.I)
164
 
165
 
166
+ def published_on(paper: dict) -> str:
167
+ """The publication date the site reads (published_date, else accepted_date):
168
+ a citation's year and the volume follow publication, as in cite.ts."""
169
+ return paper.get("published_date") or paper.get("accepted_date") or ""
170
+
171
+
172
  def cite_as(paper: dict, j: dict) -> str:
173
  """The page's "The work" plain-text citation (cite.ts plainTextWork)."""
174
  authors = authors_apa_list(paper.get("authors") or [])
175
+ year = published_on(paper)[:4]
176
  if is_institute_doi(paper["doi"], j["prefix"]):
177
+ vol = volume_for(published_on(paper), j["volumes"])
178
  art = article_number_for(paper["doi"], j["prefix"])
179
  loc = j["title"] + (f", {vol['volume']}" if vol else "") + (f", Article {art}" if art else "")
180
  return f"{authors} ({year}). {paper['title']}. {loc}. {doi_url(paper['doi'])}"
 
326
  def render_cover(paper: dict, rec: dict, site: Site, size: str = "Letter") -> bytes:
327
  j = site.journal()
328
  doi = bare_doi(paper["doi"])
329
+ vol = volume_for(published_on(paper), j["volumes"])
330
  art = article_number_for(doi, j["prefix"])
331
  issue = " &middot; ".join(x for x in (
332
  f"Volume {_escape(vol['volume'])} ({_escape(vol['year'])})" if vol else "",