nursing-ebp-tool / utils /citations.py
Lincoln Gombedza
fix: citation author parsing bug + replace broken NHS K&L Hub link
4e6aab3
Raw History Blame Contribute Delete
12.1 kB
"""
Citation formatters: Vancouver, APA 7th edition, and AMA style.
Vancouver is the standard for UK nursing and medical publications.
APA 7th is used by some UK university nursing programmes.
"""
# ---------------------------------------------------------------------------
# APA 7th Edition
# ---------------------------------------------------------------------------
def format_apa7(article: dict) -> str:
"""
APA 7th edition journal article format:
Author, F. M., & Author, F. M. (Year). Title of article.
*Journal Name*, *Volume*(Issue), Pages. https://doi.org/xxxxx
"""
authors_str = _apa_authors(article.get("authors", []))
year = article.get("year") or "n.d."
title = _clean_title(article.get("title", ""))
journal = article.get("journal", "")
volume = article.get("volume", "")
issue = article.get("issue", "")
pages = article.get("pages", "")
doi = _clean_doi(article.get("doi", ""))
url = article.get("url", "")
citation = f"{authors_str} ({year}). {title}."
if journal:
citation += f" *{journal}*"
if volume:
citation += f", *{volume}*"
if issue:
citation += f"({issue})"
if pages:
citation += f", {pages}"
citation += "."
if doi:
citation += f" https://doi.org/{doi}"
elif url:
citation += f" {url}"
return citation
def _apa_authors(authors: list[str]) -> str:
if not authors:
return "Unknown Author"
formatted = [_apa_single(a) for a in authors[:20]]
# APA 7: 21+ authors → first 19, ellipsis, last author
if len(authors) > 20:
last = _apa_single(authors[-1])
return ", ".join(formatted[:19]) + ", . . . " + last
if len(formatted) == 1:
return formatted[0]
if len(formatted) == 2:
return f"{formatted[0]}, & {formatted[1]}"
return ", ".join(formatted[:-1]) + f", & {formatted[-1]}"
def _apa_single(name: str) -> str:
name = name.strip()
if name.lower() in ("et al.", "et al"):
return "et al."
if "," in name:
# "Last, First M." → keep as-is, just ensure initials
last, *rest = name.split(",", 1)
initials = _initials(" ".join(rest))
return f"{last.strip()}, {initials}"
parts = name.split()
if len(parts) >= 2:
# Detect PubMed/Europe PMC "Surname JK" format
if _is_initials_token(parts[-1]):
last = " ".join(parts[:-1]) # handles "van der Berg JK"
initials = _initials_from_str(parts[-1])
return f"{last}, {initials}"
last = parts[-1]
initials = _initials(" ".join(parts[:-1]))
return f"{last}, {initials}"
return name
# ---------------------------------------------------------------------------
# Vancouver Style (UK nursing / medical standard)
# ---------------------------------------------------------------------------
def format_vancouver(article: dict) -> str:
"""
Vancouver citation format — standard for UK nursing and medical journals
(Nursing Standard, BJN, JAN, BMJ, Lancet, NEJM etc.)
Format: Surname Initial(s), Surname Initial(s). Title. Journal Abbrev.
Year;Volume(Issue):Pages. doi:xxxxx
Up to 6 authors; if more, list first 6 then 'et al.'
"""
authors_str = _vancouver_authors(article.get("authors", []))
year = article.get("year", "")
title = _clean_title(article.get("title", ""))
journal = article.get("journal", "")
volume = article.get("volume", "")
issue = article.get("issue", "")
pages = article.get("pages", "")
doi = _clean_doi(article.get("doi", ""))
url = article.get("url", "")
# Vancouver: no period after title if journal follows immediately
citation = f"{authors_str}. {title}."
if journal:
citation += f" {journal}."
if year:
citation += f" {year}"
if volume:
citation += f";{volume}"
if issue:
citation += f"({issue})"
if pages:
citation += f":{pages}"
citation += "."
if doi:
citation += f" doi:{doi}"
elif url:
citation += f" Available from: {url}"
return citation
def _vancouver_authors(authors: list[str]) -> str:
if not authors:
return "Unknown Author"
formatted = [_vancouver_single(a) for a in authors[:6]]
if len(authors) > 6:
formatted.append("et al")
return ", ".join(formatted)
def _vancouver_single(name: str) -> str:
"""Convert name to Vancouver format: Surname AB"""
name = name.strip()
if name.lower() in ("et al.", "et al"):
return "et al"
if "," in name:
last, *rest = name.split(",", 1)
initials = "".join(p[0].upper() for p in " ".join(rest).split() if p)
return f"{last.strip()} {initials}"
parts = name.split()
if len(parts) >= 2:
# Detect PubMed/Europe PMC "Surname JK" format
if _is_initials_token(parts[-1]):
last = " ".join(parts[:-1])
initials = parts[-1].upper() # already run-together: "JK" stays "JK"
return f"{last} {initials}"
last = parts[-1]
initials = "".join(p[0].upper() for p in parts[:-1] if p)
return f"{last} {initials}"
return name
# ---------------------------------------------------------------------------
# Harvard Style (Cite Them Right — common UK university nursing standard)
# ---------------------------------------------------------------------------
def format_harvard(article: dict) -> str:
"""
Harvard citation format (Cite Them Right 12th ed.) — used by many UK
universities for nursing essays where Vancouver is not mandated.
Format: Surname, Initial(s). and Surname, Initial(s). (Year) 'Title of
article', *Journal Name*, Volume(Issue), pp. Pages.
Up to 3 authors listed; 4+ → first author et al.
"""
authors_str = _harvard_authors(article.get("authors", []))
year = article.get("year") or "n.d."
title = _clean_title(article.get("title", ""))
journal = article.get("journal", "")
volume = article.get("volume", "")
issue = article.get("issue", "")
pages = article.get("pages", "")
doi = _clean_doi(article.get("doi", ""))
url = article.get("url", "")
citation = f"{authors_str} ({year}) '{title}',"
if journal:
citation += f" *{journal}*,"
if volume:
citation += f" {volume}"
if issue:
citation += f"({issue})"
citation += ","
if pages:
citation += f" pp. {pages}."
else:
citation = citation.rstrip(",") + "."
else:
citation = citation.rstrip(",") + "."
if doi:
citation += f" doi:{doi}"
elif url:
citation += f" Available at: {url} (Accessed: {_access_year(year)})"
return citation
def _harvard_authors(authors: list[str]) -> str:
if not authors:
return "Anon."
if len(authors) > 3:
return _harvard_single(authors[0]) + " et al."
formatted = [_harvard_single(a) for a in authors]
if len(formatted) == 1:
return formatted[0]
return ", ".join(formatted[:-1]) + " and " + formatted[-1]
def _harvard_single(name: str) -> str:
"""Convert to Harvard format: Surname, A.B."""
name = name.strip()
if name.lower() in ("et al.", "et al"):
return "et al."
if "," in name:
last, *rest = name.split(",", 1)
initials = _initials(" ".join(rest))
return f"{last.strip()}, {initials}"
parts = name.split()
if len(parts) >= 2:
# Detect PubMed/Europe PMC "Surname JK" format (e.g. "Smith JK")
# Without this check, "Smith JK" → last="JK", initials="S." → "JK, S." (wrong)
if _is_initials_token(parts[-1]):
last = " ".join(parts[:-1]) # handles multi-word surnames too
initials = _initials_from_str(parts[-1]) # "JK" → "J. K."
return f"{last}, {initials}"
last = parts[-1]
initials = _initials(" ".join(parts[:-1]))
return f"{last}, {initials}"
return name
def _access_year(year: str) -> str:
"""Return a sensible access date placeholder."""
return "date accessed"
# ---------------------------------------------------------------------------
# AMA Style
# ---------------------------------------------------------------------------
def format_ama(article: dict) -> str:
"""
AMA citation format (used in some nursing / medical journals):
Last FM, Last FM. Title. Journal. Year;Vol(Issue):Pages. doi:xxxxx
"""
authors_str = _ama_authors(article.get("authors", []))
year = article.get("year", "")
title = _clean_title(article.get("title", ""))
journal = article.get("journal", "")
volume = article.get("volume", "")
issue = article.get("issue", "")
pages = article.get("pages", "")
doi = _clean_doi(article.get("doi", ""))
citation = f"{authors_str}. {title}. *{journal}*."
if year:
citation += f" {year}"
if volume:
citation += f";{volume}"
if issue:
citation += f"({issue})"
if pages:
citation += f":{pages}"
citation += "."
if doi:
citation += f" doi:{doi}"
return citation
def _ama_authors(authors: list[str]) -> str:
if not authors:
return "Unknown Author"
formatted = [_ama_single(a) for a in authors[:6]]
if len(authors) > 6:
formatted.append("et al")
return ", ".join(formatted)
def _ama_single(name: str) -> str:
name = name.strip()
if name.lower() in ("et al.", "et al", "et al."):
return "et al"
if "," in name:
last, *rest = name.split(",", 1)
initials = "".join(p[0].upper() for p in " ".join(rest).split() if p)
return f"{last.strip()} {initials}"
parts = name.split()
if len(parts) >= 2:
# Detect PubMed/Europe PMC "Surname JK" format
if _is_initials_token(parts[-1]):
last = " ".join(parts[:-1])
initials = parts[-1].upper() # AMA uses run-together: "Smith JK"
return f"{last} {initials}"
last = parts[-1]
initials = "".join(p[0].upper() for p in parts[:-1] if p)
return f"{last} {initials}"
return name
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _initials(first_middle: str) -> str:
"""Convert first/middle name string to initials: "John Paul" → "J. P." """
parts = first_middle.strip().split()
return " ".join(p[0].upper() + "." for p in parts if p)
def _initials_from_str(token: str) -> str:
"""
Expand a run-together initials token from PubMed/Europe PMC into dotted
initials: "JK" → "J. K." | "AB" → "A. B." | "J" → "J."
"""
return " ".join(c.upper() + "." for c in token if c.isalpha())
def _is_initials_token(token: str) -> bool:
"""
Return True when a name token looks like run-together initials rather than
a proper word — i.e. it is 1–4 uppercase letters with no vowels OR is
entirely uppercase and short.
Examples that return True : "JK", "AB", "J", "JKL", "ABCD"
Examples that return False: "Smith", "John", "van", "De", "O'Brien"
"""
if not token or not token.isalpha():
return False
if len(token) > 4:
return False
# Single letter is always an initial
if len(token) == 1:
return token.isupper()
# Multi-char: must be all-uppercase (PubMed stores "Smith JK", not "Smith jk")
return token.isupper()
def _clean_title(title: str) -> str:
return title.strip().rstrip(".")
def _clean_doi(doi: str) -> str:
doi = doi.strip()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:", "doi: "):
if doi.lower().startswith(prefix):
doi = doi[len(prefix):]
return doi