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,
}
|