Skip to content

zettelkasten.bibliography

zettelkasten.bibliography

Bibliography assembly + formatting for sources and reviews.

Shared by the MCP export_bibliography tool (project-wide: every source + citation) and the dashboard's review bibliography route (home sources of a review's claims). A bibliography entry is a normalized dict::

{key, title, authors, year, venue, doi, doc_type}

and the two emitters — :func:to_bibtex (a .bib file) and :func:to_formatted (an author-year reference list) — consume exactly that shape, so the same records render either way. Callers decide which works go in; this module only normalizes and renders them.

bibtex_key

bibtex_key(raw: str) -> str

Sanitize raw into a valid BibTeX / \cite key.

Folds any run of disallowed characters to a single underscore and trims leading/trailing separators. Falls back to "ref" for an empty result so a key is never blank (which BibTeX rejects).

Source code in zettelkasten/bibliography.py
def bibtex_key(raw: str) -> str:
    """Sanitize ``raw`` into a valid BibTeX / ``\\cite`` key.

    Folds any run of disallowed characters to a single underscore and trims
    leading/trailing separators. Falls back to ``"ref"`` for an empty result so
    a key is never blank (which BibTeX rejects).
    """
    key = _KEY_SANITIZE_RE.sub("_", (raw or "").strip())
    key = key.strip("_-")
    return key or "ref"

unique_key

unique_key(base: str, taken: Iterable[str]) -> str

base if free, else base_2 / base_3 / … avoiding taken.

Two distinct sources can sanitize to the same key (e.g. a.b and a/b); this keeps every .bib entry key — and thus every \cite target — unambiguous.

Source code in zettelkasten/bibliography.py
def unique_key(base: str, taken: Iterable[str]) -> str:
    """``base`` if free, else ``base_2`` / ``base_3`` / … avoiding ``taken``.

    Two distinct sources can sanitize to the same key (e.g. ``a.b`` and ``a/b``);
    this keeps every ``.bib`` entry key — and thus every ``\\cite`` target —
    unambiguous.
    """
    taken_set = set(taken)
    if base not in taken_set:
        return base
    i = 2
    while f"{base}_{i}" in taken_set:
        i += 1
    return f"{base}_{i}"

bib_entry_from_meta

bib_entry_from_meta(key: str, meta: dict, *, fallback_title: str = '') -> dict

Build a normalized bibliography entry from a source/citation metadata dict.

Works for both a source _meta.yaml and a _citations/*.yaml entry — they share the title/authors/year/venue/doi/doc_type vocabulary. key is used verbatim as the BibTeX cite key (sanitize it with :func:bibtex_key first when it comes from an arbitrary id).

Source code in zettelkasten/bibliography.py
def bib_entry_from_meta(key: str, meta: dict, *, fallback_title: str = "") -> dict:
    """Build a normalized bibliography entry from a source/citation metadata dict.

    Works for both a source ``_meta.yaml`` and a ``_citations/*.yaml`` entry —
    they share the ``title``/``authors``/``year``/``venue``/``doi``/``doc_type``
    vocabulary. ``key`` is used verbatim as the BibTeX cite key (sanitize it with
    :func:`bibtex_key` first when it comes from an arbitrary id).
    """
    return {
        "key": key,
        "title": meta.get("title") or fallback_title or key,
        "authors": _normalize_authors(meta.get("authors")),
        "year": meta.get("year"),
        "venue": meta.get("venue") or "",
        "doi": meta.get("doi") or "",
        "doc_type": meta.get("doc_type") or "",
    }

to_bibtex

to_bibtex(entries: Iterable[dict]) -> str

Render entries as a BibTeX .bib document.

doc_type selects the entry type (@article / @book / @misc); a venue maps to journal for articles and publisher otherwise. Missing authors render as Unknown so every record stays well-formed.

Source code in zettelkasten/bibliography.py
def to_bibtex(entries: Iterable[dict]) -> str:
    """Render entries as a BibTeX ``.bib`` document.

    ``doc_type`` selects the entry type (``@article`` / ``@book`` / ``@misc``);
    a venue maps to ``journal`` for articles and ``publisher`` otherwise. Missing
    authors render as ``Unknown`` so every record stays well-formed.
    """
    blocks: list[str] = []
    for entry in entries:
        doc_type = entry.get("doc_type") or "misc"
        bib_type = (
            "article" if "article" in doc_type
            else "book" if "book" in doc_type
            else "misc"
        )
        authors = entry.get("authors") or []
        authors_str = " and ".join(authors) if authors else "Unknown"
        bib = f"@{bib_type}{{{entry['key']},\n"
        bib += f"  title = {{{entry['title']}}},\n"
        bib += f"  author = {{{authors_str}}},\n"
        year = entry.get("year")
        if year:
            bib += f"  year = {{{year}}},\n"
        if entry.get("venue"):
            field = "journal" if bib_type == "article" else "publisher"
            bib += f"  {field} = {{{entry['venue']}}},\n"
        if entry.get("doi"):
            bib += f"  doi = {{{entry['doi']}}},\n"
        bib += "}"
        blocks.append(bib)
    return "\n\n".join(blocks)

to_formatted

to_formatted(entries: Iterable[dict]) -> str

Render entries as an author-year reference list (one work per line).

Sorted by year then key — the same ordering export_bibliography's formatted mode has always used.

Source code in zettelkasten/bibliography.py
def to_formatted(entries: Iterable[dict]) -> str:
    """Render entries as an author-year reference list (one work per line).

    Sorted by year then key — the same ordering ``export_bibliography``'s
    ``formatted`` mode has always used.
    """
    rows = sorted(entries, key=lambda e: (e.get("year") or 9999, e["key"]))
    lines: list[str] = []
    for entry in rows:
        authors = ", ".join(entry["authors"]) if entry.get("authors") else "Unknown"
        year = f"({entry['year']})" if entry.get("year") else "(n.d.)"
        venue = f" {entry['venue']}." if entry.get("venue") else ""
        lines.append(f"{authors} {year}. {entry['title']}.{venue}")
    return "\n".join(lines)

collect_review_sources

collect_review_sources(view: dict, load_meta: Callable[[str], dict]) -> tuple[list[dict], dict[str, str]]

Home-source bibliography for a reconciled review view.

Walks every claim in the view (placed in themes[].claims and the unplaced worklist), resolves each to its :func:_home_source, and keeps the deduplicated set of sources that have real metadata. load_meta reads a source's _meta.yaml (returning {} when absent); a source with neither title nor authors is treated as a bare stub and skipped.

Returns (entries, key_map):

  • entries — the de-duplicated normalized entries, ready for :func:to_bibtex / :func:to_formatted.
  • key_map — each claim uid mapped to the BibTeX key of its home source, so the exported markdown can render \cite{key}. A claim whose home source lacks metadata is simply absent from the map.
Source code in zettelkasten/bibliography.py
def collect_review_sources(
    view: dict,
    load_meta: Callable[[str], dict],
) -> tuple[list[dict], dict[str, str]]:
    """Home-source bibliography for a reconciled review view.

    Walks every claim in the view (placed in ``themes[].claims`` and the
    ``unplaced`` worklist), resolves each to its :func:`_home_source`, and keeps
    the deduplicated set of sources that have real metadata. ``load_meta`` reads a
    source's ``_meta.yaml`` (returning ``{}`` when absent); a source with neither
    title nor authors is treated as a bare stub and skipped.

    Returns ``(entries, key_map)``:

    * ``entries`` — the de-duplicated normalized entries, ready for
      :func:`to_bibtex` / :func:`to_formatted`.
    * ``key_map`` — each claim ``uid`` mapped to the BibTeX key of its home
      source, so the exported markdown can render ``\\cite{key}``. A claim whose
      home source lacks metadata is simply absent from the map.
    """
    entries: dict[str, dict] = {}     # source name -> normalized entry
    source_key: dict[str, str] = {}   # source name -> assigned bibtex key
    key_map: dict[str, str] = {}      # claim uid -> bibtex key

    def claim_rows() -> Iterable[dict]:
        for theme in view.get("themes") or []:
            yield from theme.get("claims") or []
        yield from view.get("unplaced") or []

    for claim in claim_rows():
        home = _home_source(claim)
        if not home:
            continue
        if home not in source_key:
            meta = load_meta(home) or {}
            # A real bibliographic record needs at least a title or authors; a
            # bare/empty _meta.yaml (e.g. an unowned stub) is not a citation.
            if not (meta.get("title") or meta.get("authors")):
                continue
            key = unique_key(bibtex_key(home), source_key.values())
            source_key[home] = key
            entries[home] = bib_entry_from_meta(key, meta, fallback_title=home)
        uid = claim.get("uid")
        if uid:
            key_map[uid] = source_key[home]

    return list(entries.values()), key_map