Skip to content

zettelkasten.export

zettelkasten.export

Portable, read-only exporters over a loaded zettelkasten graph.

Two serializers, both PURE traversals — they read a loaded :class:~zettelkasten.graph.ZettelGraph (or a bare iterable of :class:~zettelkasten.graph.Note) and mutate NOTHING on disk or in memory:

  • :func:graph_to_gexf — a GEXF 1.2 document (notes → <node>, links → <edge>) for Gephi/networkx. Built with the safe :mod:memory.gexf helper (construct-only XML; no XXE surface).
  • :func:graph_to_vault — an Obsidian-style markdown vault: one .md per note with YAML frontmatter (including the note id) plus each link rendered as a Dataview-style TYPED wikilink (relation:: contradicts [[Target Title]] (confidence:: 0.8)). The link target is a resolvable title, or the target note's id when the title is absent/ambiguous/unsafe. This convention is the exact inverse of :mod:zettelkasten.import_vault, so an exported vault re-imports losslessly — and, because the id rides in frontmatter, re-importing into the SAME box is idempotent (dedup on note identity, no duplicates).

Nothing here touches embeddings, the .kgl cache, or the write path — these are safe to call from a write-free (ZK_DISABLE_WRITE) server instance.

graph_to_gexf

graph_to_gexf(graph_or_notes) -> str

Serialize a loaded graph (or note iterable) into a GEXF 1.2 document.

Each note becomes a <node> (id, label = title, attributes type/tags/status/source); each outgoing link becomes an <edge> (source/target, label = relation, weight = confidence when present). Notes are iterated once; the underlying builder streams elements, so this stays linear in nodes + edges for large graphs.

Source code in zettelkasten/export.py
def graph_to_gexf(graph_or_notes) -> str:
    """Serialize a loaded graph (or note iterable) into a GEXF 1.2 document.

    Each note becomes a ``<node>`` (``id``, ``label`` = title, attributes
    ``type``/``tags``/``status``/``source``); each outgoing link becomes an
    ``<edge>`` (``source``/``target``, ``label`` = relation, ``weight`` =
    confidence when present). Notes are iterated once; the underlying builder
    streams elements, so this stays linear in nodes + edges for large graphs.
    """
    notes = _iter_notes(graph_or_notes)

    def _nodes():
        for note in notes:
            yield (
                note.id,
                note.title,
                {
                    "type": note.type,
                    "tags": ", ".join(note.tags),
                    "status": note.status,
                    "source": _source_label(note.source),
                },
            )

    def _edges():
        for note in notes:
            for link in note.links:
                # Tombstoned (soft-deleted) edges are not part of the live graph.
                if getattr(link, "tombstoned", False):
                    continue
                yield (note.id, link.target, link.relation, link.confidence)

    return build_gexf(_nodes(), _edges(), node_attributes=_NODE_ATTRIBUTES)

note_to_vault_markdown

note_to_vault_markdown(note, notes_by_id, dup_titles: set[str] | None = None) -> str

Render one note as an Obsidian-vault markdown document.

YAML frontmatter carries the note id (so a re-import can dedup on note identity and round-trip idempotently) plus title/type/tags/ aliases/status; the body follows an # Title heading; each outgoing link is appended as a Dataview-style typed wikilink line (relation:: <REL> [[target]] with optional (confidence:: …) / (direction:: …) subfields). notes_by_id resolves link targets to titles (or ids) for round-trippable wikilinks; dup_titles (the set of ambiguous lowercased titles) is computed from notes_by_id when omitted.

Source code in zettelkasten/export.py
def note_to_vault_markdown(note, notes_by_id, dup_titles: set[str] | None = None) -> str:
    """Render one note as an Obsidian-vault markdown document.

    YAML frontmatter carries the note ``id`` (so a re-import can dedup on note
    identity and round-trip idempotently) plus ``title``/``type``/``tags``/
    ``aliases``/``status``; the body follows an ``# Title`` heading; each
    outgoing link is appended as a Dataview-style typed wikilink line
    (``relation:: <REL> [[target]]`` with optional ``(confidence:: …)`` /
    ``(direction:: …)`` subfields). ``notes_by_id`` resolves link targets to
    titles (or ids) for round-trippable wikilinks; ``dup_titles`` (the set of
    ambiguous lowercased titles) is computed from ``notes_by_id`` when omitted.
    """
    frontmatter = {
        "id": note.id,
        "title": note.title,
        "type": note.type,
        "tags": list(note.tags),
        "aliases": list(note.aliases),
        "status": note.status,
    }
    yaml_str = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False, allow_unicode=True)

    lines = [f"---\n{yaml_str}---\n", f"# {note.title}\n"]
    if note.body:
        lines.append(f"{note.body}\n")

    if dup_titles is None:
        dup_titles = _ambiguous_titles(notes_by_id)
    link_lines = []
    for link in note.links:
        if getattr(link, "tombstoned", False):
            continue
        link_lines.append(_render_link_line(note, link, notes_by_id, dup_titles))
    if link_lines:
        lines.append("\n".join(link_lines) + "\n")

    return "\n".join(lines)

graph_to_vault

graph_to_vault(graph_or_notes, out_dir: Path | str) -> dict

Write one .md per note under out_dir (Obsidian vault export).

Returns a manifest {out_dir, note_count, files, warnings}. The output directory is created if needed. Filenames are derived from note titles (sanitized) and are guaranteed to stay inside out_dir. warnings flags best-effort fidelity gaps (e.g. cross-graph link targets that cannot be resolved within this box).

Source code in zettelkasten/export.py
def graph_to_vault(graph_or_notes, out_dir: Path | str) -> dict:
    """Write one ``.md`` per note under ``out_dir`` (Obsidian vault export).

    Returns a manifest ``{out_dir, note_count, files, warnings}``. The output
    directory is created if needed. Filenames are derived from note titles
    (sanitized) and are guaranteed to stay inside ``out_dir``. ``warnings`` flags
    best-effort fidelity gaps (e.g. cross-graph link targets that cannot be
    resolved within this box).
    """
    notes = _iter_notes(graph_or_notes)
    notes_by_id = {n.id: n for n in notes}
    dup_titles = _ambiguous_titles(notes_by_id)
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    root = out.resolve()

    taken: set[str] = set()
    files: list[str] = []
    warnings: list[str] = []
    for note in notes:
        stem = _vault_filename(note, taken)
        path = (out / f"{stem}.md").resolve()
        # Defense-in-depth: a sanitized title-derived stem cannot contain a
        # separator, but assert containment before writing regardless.
        if root != path and not path.is_relative_to(root):
            raise ValueError(f"vault filename escapes export dir: {stem!r}")
        for link in note.links:
            if getattr(link, "tombstoned", False):
                continue
            # A link that names another box, or points at a target absent from
            # this export, cannot be resolved on re-import into a single box.
            if getattr(link, "graph", "") or link.target not in notes_by_id:
                warnings.append(
                    f"{note.id}: cross-graph/dangling link to '{link.target}' "
                    f"(relation '{link.relation}') exported as a bare id"
                )
        path.write_text(
            note_to_vault_markdown(note, notes_by_id, dup_titles), encoding="utf-8"
        )
        files.append(path.name)

    return {
        "out_dir": str(root),
        "note_count": len(notes),
        "files": files,
        "warnings": warnings,
    }