"""Resolve and format journal names from ADS bibstems."""
import json
import re
import warnings
from pathlib import Path
from typing import Any, Literal
try:
import requests
except ImportError:
requests = None
from .config import Config
__all__ = ["JOURNAL_MACRO", "format_journal"]
JOURNAL_ABBREV_URL = "https://adsabs.harvard.edu/abs_doc/journals1.html"
CACHE_FILENAME = "journal_abbrevs.json"
# LaTeX macros for common astronomy journals
JOURNAL_MACRO = {
"aas": "American Astronomical Society Meeting Abstracts",
"aj": "Astronomical Journal",
"actaa": "Acta Astronomica",
"araa": "Annual Review of Astron and Astrophysis",
"apjl": "Astrophysical Journal, Letters",
"apjs": "Astrophysical Journal, Supplement",
"apj": "Astrophysical Journal",
"ao": "Applied Optics",
"apss": "Astrophysics and Space Science",
"aapr": "Astronomy and Astrophysics Reviews",
"aaps": "Astronomy and Astrophysics, Supplement",
"aap": "Astronomy and Astrophysics",
"aplett": "Astrophysics Letters",
"apspr": "Astrophysics Space Physics Research",
"azh": "Astronomicheskii Zhurnal",
"baas": "Bulletin of the American Astronomical Society",
"bac": "Bulletin of the Astronomical Institutes of Czechoslovakia",
"bain": "Bulletin Astronomical Institute of the Netherlands",
"caa": "Chinese Astronomy and Astrophysics",
"cjaa": "Chinese Journal of Astronomy and Astrophysics",
"dps": "American Astronomical Society/Division for Planetary Sciences Meeting Abstracts",
"fcp": "Fundamental Cosmic Physics",
"gca": "Geochimica Cosmochimica Acta",
"grl": "Geophysics Research Letters",
"iaucirc": "International Astronomical Union Cirulars",
"icarus": "Icarus",
"jaavso": "Journal of the American Association of Variable Star Observers",
"jcap": "Journal of Cosmology and Astroparticle Physics",
"jcp": "Journal of Chemical Physics",
"jgr": "Journal of Geophysics Research",
"jqsrt": "Journal of Quantitiative Spectroscopy and Radiative Transfer",
"jrasc": "Journal of the Royal Astronomical Society of Canada",
"maps": "Meteoritics and Planetary Science",
"memras": "Memoirs of the Royal Astronomical Society",
"memsai": "Mem. Societa Astronomica Italiana",
"mnras": "Monthly Notices of the Royal Astronomical Society",
"nat": "Nature",
"nar": "New Astronomy Review",
"na": "New Astronomy",
"nphysa": "Nuclear Physics A",
"pasa": "Publications of the Astronomical Society of Australia",
"pasp": "Publications of the Astronomical Society of the Pacific",
"pasj": "Publications of the Astronomical Society of Japan",
"physrep": "Physics Reports",
"physscr": "Physica Scripta",
"planss": "Planetary Space Science",
"pra": "Physical Review A",
"prb": "Physical Review B",
"prc": "Physical Review C",
"prd": "Physical Review D",
"pre": "Physical Review E",
"prl": "Physical Review Letters",
"procspie": "Proceedings of the Society of Photo-Optical Instrumentation Engineers",
"psj": "Planetary Science Journal",
"qjras": "Quarterly Journal of the Royal Astronomical Society",
"rmxaa": "Revista Mexicana de Astronomia y Astrofisica",
"skytel": "Sky and Telescope",
"solphys": "Solar Physics",
"sovast": "Soviet Astronomy",
"ssr": "Space Science Reviews",
"zap": "Zeitschrift fuer Astrophysik",
}
# Create reverse mapping for ADS formatting (Title -> Macro)
TITLE_TO_MACRO = {v: f"\\{k}" for k, v in JOURNAL_MACRO.items()}
MANUAL_OVERRIDES = {
"ApJL": {
"full": "The Astrophysical Journal Letters",
"iso4": "Astrophys. J. Lett.",
},
}
def _normalize_name(name: str) -> str:
"""Normalize journal name for fuzzy matching (lowercase, no punctuation/spaces)."""
if name.lower().startswith("the "):
name = name[4:]
return re.sub(r"[^a-z0-9]", "", name.lower())
# The loaded map, keyed by the cache file path and its size and mtime.
_MAP_MEMO: tuple[tuple[Any, ...], dict[str, dict[str, str]]] | None = None
# Normalized full names of one map, and the map they belong to.
_INDEX_MEMO: tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]] | None = None
def _get_journal_map() -> dict[str, dict[str, str]]:
"""Return the journal abbreviation map, loading it once per cache state.
The map maps ``{bibstem: {"full": full_name, "iso4": iso4_name}}``. A
changed cache file loads again. When no cache file exists and the fetch
fails, the empty result is kept for this process, so a list render does
not retry the network for every paper.
"""
global _MAP_MEMO
cache_path = Config.get_config_dir() / CACHE_FILENAME
try:
stat = cache_path.stat()
state: tuple[Any, ...] = (str(cache_path), stat.st_size, stat.st_mtime_ns)
except OSError:
state = (str(cache_path), None, None)
if _MAP_MEMO is not None and _MAP_MEMO[0] == state:
return _MAP_MEMO[1]
journal_map = _load_journal_map(cache_path)
try:
stat = cache_path.stat()
state = (str(cache_path), stat.st_size, stat.st_mtime_ns)
except OSError:
pass
_MAP_MEMO = (state, journal_map)
return journal_map
def _normalized_index(
journal_map: dict[str, dict[str, str]],
) -> dict[str, dict[str, str]]:
"""Return the first entry for each normalized full name of `journal_map`."""
global _INDEX_MEMO
if _INDEX_MEMO is not None and _INDEX_MEMO[0] is journal_map:
return _INDEX_MEMO[1]
index: dict[str, dict[str, str]] = {}
for entry in journal_map.values():
index.setdefault(_normalize_name(entry.get("full", "")), entry)
_INDEX_MEMO = (journal_map, index)
return index
def _load_journal_map(cache_path: Path) -> dict[str, dict[str, str]]:
"""Load journal abbreviation map from cache or fetch from ADS."""
if cache_path.exists():
try:
data = json.loads(cache_path.read_text(encoding="utf-8"))
# Check format: simple dict (old) vs nested dict (new)
if data and isinstance(next(iter(data.values())), str):
warnings.warn("Old journal cache format detected. Re-fetching.")
else:
data.update(MANUAL_OVERRIDES)
return data
except (OSError, json.JSONDecodeError) as e:
warnings.warn(f"Failed to read journal cache: {e}. Re-fetching.")
if requests is None:
warnings.warn(
"Requests library not installed. Cannot fetch journal abbreviations."
)
return {}
try:
response = requests.get(JOURNAL_ABBREV_URL, timeout=10)
response.raise_for_status()
content = response.text
except requests.RequestException as e:
warnings.warn(f"Failed to fetch journal abbreviations: {e}")
return {}
# Parse HTML
# Pattern: <a href="#" onClick="setfields('ApJ..');"> ApJ </a> Astrophysical Journal
pattern = r"onClick=\"setfields\('([^']+)'\);\">.*?</a>\s*(.+?)(?:\r|\n|<)"
matches = re.findall(pattern, content)
# Prepare ISO4 function (not used for eager computation anymore, but kept if we want to enable it later)
# Actually, we skip eager computation to avoid 2-minute startup delay.
journal_map = {}
for stem, name in matches:
stem = stem.strip()
full_name = name.strip()
# Initialize with None for iso4 to compute on demand
entry = {"full": full_name, "iso4": None}
journal_map[stem] = entry
journal_map.update(MANUAL_OVERRIDES)
# Save to cache
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(journal_map, indent=2), encoding="utf-8")
except OSError as e:
warnings.warn(f"Failed to save journal cache: {e}")
return journal_map