Skip to content

zettelkasten.outline_registry

zettelkasten.outline_registry

Multi-outline registry: a review's outlines as first-class instances.

A review (_reviews/<name>.yaml) is a container; its research TOOLS — the Outline and the Matrix — live under it. Matrix tables are already multi-instance (_reviews/<name>.tables.json), but the outline was historically single: its whole structure lived in the review manifest's overlay. This module makes outlines first-class and multi-instance, each with its own research QUESTION and its own structural overlay.

Storage model — default in the manifest, extras in a sidecar

The default outline (id "outline") IS the historical single outline: its structure stays in the manifest overlay, its scaffold cache in the manifest scaffold key, and its markdown at _reviews/<name>.md — untouched. Its question is the review's own question. This makes the default path identical to the pre-multi-outline behavior (nothing migrates, nothing can drift).

Additional outlines live in the sidecar _reviews/<name>.outlines.json (written through the M3a substrate: review_write_lockatomic_write_text_schedule_zettel_commit), each a record {id, title, question, overlay, scaffold} with its markdown at _reviews/<name>.<id>.md. The sidecar NEVER contains the default outline.

Overlay split

The manifest overlay carries two kinds of state:

  • review-widetiering, exclusions, favorites: which claims matter and how, shared by EVERY outline. Always read from the manifest.
  • per-outlineordering, section_renames, section_notes, scaffolding, placements, proposed_claims, proposed_themes, promote_undo, saved_structure, section_framing, apex: the themed STRUCTURE of one outline (section_framing/apex carry the spine-DERIVED framing a "Detach from spine" snapshots — OUTLINE-LOCAL, never review-wide). For the default outline these live in the manifest; for additional outlines they live in the sidecar record.

:func:merged_overlay recombines the shared review-wide half with one outline's per-outline half into the full overlay shape that :func:zettelkasten.review.apply_overlay consumes.

split_overlay

split_overlay(overlay: Any) -> tuple[dict[str, Any], dict[str, Any]]

Split a manifest overlay into (review_wide, outline_overlay).

Both halves are normalized to the full per-key shape, so a partial / None overlay still yields complete sub-dicts.

Source code in zettelkasten/outline_registry.py
def split_overlay(overlay: Any) -> tuple[dict[str, Any], dict[str, Any]]:
    """Split a manifest overlay into ``(review_wide, outline_overlay)``.

    Both halves are normalized to the full per-key shape, so a partial / ``None``
    overlay still yields complete sub-dicts.
    """
    from zettelkasten.review import _normalize_overlay

    full = _normalize_overlay(overlay)
    review_wide = {k: copy.deepcopy(full[k]) for k in REVIEW_OVERLAY_KEYS}
    outline_overlay = {k: copy.deepcopy(full[k]) for k in OUTLINE_OVERLAY_KEYS}
    return review_wide, outline_overlay

merge_overlay

merge_overlay(review_wide: dict[str, Any], outline_overlay: dict[str, Any]) -> dict[str, Any]

Recombine a shared review-wide half + one outline's per-outline half.

Returns the full overlay shape :func:review.apply_overlay expects.

Source code in zettelkasten/outline_registry.py
def merge_overlay(review_wide: dict[str, Any], outline_overlay: dict[str, Any]) -> dict[str, Any]:
    """Recombine a shared review-wide half + one outline's per-outline half.

    Returns the full overlay shape :func:`review.apply_overlay` expects.
    """
    from zettelkasten.review import _normalize_overlay

    merged: dict[str, Any] = {}
    for k in REVIEW_OVERLAY_KEYS:
        merged[k] = review_wide.get(k)
    for k in OUTLINE_OVERLAY_KEYS:
        merged[k] = outline_overlay.get(k)
    return _normalize_overlay(merged)

draft_path

draft_path(name: str, outline_id: str, base: Path) -> Path

The markdown artifact path for an outline.

The default outline keeps the legacy _reviews/<name>.md path; additional outlines namespace their markdown by id.

Source code in zettelkasten/outline_registry.py
def draft_path(name: str, outline_id: str, base: Path) -> Path:
    """The markdown artifact path for an outline.

    The default outline keeps the legacy ``_reviews/<name>.md`` path; additional
    outlines namespace their markdown by id.
    """
    base = Path(base)
    if outline_id == DEFAULT_OUTLINE_ID:
        return base / "_reviews" / f"{name}.md"
    return base / "_reviews" / f"{name}.{outline_id}.md"

ensure_outlines

ensure_outlines(name: str, *, graphs_dir: 'Path | None' = None, manifest: dict[str, Any] | None = None) -> dict[str, Any]

Return the outlines store {"outlines": {id: record}} for a review.

The default outline (derived from the manifest) is always present when the review exists; additional outlines come from the sidecar. Pass manifest to avoid a redundant load.

Source code in zettelkasten/outline_registry.py
def ensure_outlines(
    name: str, *, graphs_dir: "Path | None" = None, manifest: dict[str, Any] | None = None
) -> dict[str, Any]:
    """Return the outlines store ``{"outlines": {id: record}}`` for a review.

    The default outline (derived from the manifest) is always present when the
    review exists; additional outlines come from the sidecar. Pass ``manifest`` to
    avoid a redundant load.
    """
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    if manifest is None:
        manifest = load_review(name, graphs_dir=base)
    store: dict[str, Any] = {}
    if manifest:
        store[DEFAULT_OUTLINE_ID] = _synth_default(manifest)
    store.update(_load_sidecar(name, base))
    return {"outlines": store}

get_outline

get_outline(name: str, outline_id: str, *, graphs_dir: 'Path | None' = None, manifest: dict[str, Any] | None = None) -> dict[str, Any] | None

A single outline record, or None if it does not exist.

Source code in zettelkasten/outline_registry.py
def get_outline(
    name: str, outline_id: str, *, graphs_dir: "Path | None" = None, manifest: dict[str, Any] | None = None
) -> dict[str, Any] | None:
    """A single outline record, or ``None`` if it does not exist."""
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    if outline_id == DEFAULT_OUTLINE_ID:
        if manifest is None:
            manifest = load_review(name, graphs_dir=base)
        return _synth_default(manifest) if manifest else None
    return _load_sidecar(name, base).get(outline_id)

merged_overlay

merged_overlay(name: str, outline_id: str, *, graphs_dir: 'Path | None' = None, manifest: dict[str, Any] | None = None) -> dict[str, Any] | None

The full overlay for an outline: shared review-wide + that outline's structure.

Returns None when there is nothing to honor — no manifest (the default outline's review does not exist), or an unknown additional outline id — so callers fall back to the graph-derived ordering exactly as before.

Source code in zettelkasten/outline_registry.py
def merged_overlay(
    name: str, outline_id: str, *, graphs_dir: "Path | None" = None, manifest: dict[str, Any] | None = None
) -> dict[str, Any] | None:
    """The full overlay for an outline: shared review-wide + that outline's structure.

    Returns ``None`` when there is nothing to honor — no manifest (the default
    outline's review does not exist), or an unknown additional outline id — so
    callers fall back to the graph-derived ordering exactly as before.
    """
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    if manifest is None:
        manifest = load_review(name, graphs_dir=base)
    if not manifest:
        return None
    review_wide, default_outline_overlay = split_overlay(manifest.get("overlay"))
    if outline_id == DEFAULT_OUTLINE_ID:
        # Identical to the historical manifest overlay (review-wide + default).
        return merge_overlay(review_wide, default_outline_overlay)
    rec = _load_sidecar(name, base).get(outline_id)
    if rec is None:
        return None
    return merge_overlay(review_wide, rec["overlay"])

get_question

get_question(name: str, outline_id: str, *, graphs_dir: 'Path | None' = None, manifest: dict[str, Any] | None = None) -> str

An outline's research question (default → the review's own question).

Source code in zettelkasten/outline_registry.py
def get_question(
    name: str, outline_id: str, *, graphs_dir: "Path | None" = None, manifest: dict[str, Any] | None = None
) -> str:
    """An outline's research question (default → the review's own question)."""
    rec = get_outline(name, outline_id, graphs_dir=graphs_dir, manifest=manifest)
    return rec["question"] if rec else ""

read_draft_cache

read_draft_cache(name: str, outline_id: str, *, graphs_dir: 'Path | None' = None, manifest: dict[str, Any] | None = None) -> dict[str, Any] | None

The stored scaffold cache block for an outline (or None).

Source code in zettelkasten/outline_registry.py
def read_draft_cache(
    name: str, outline_id: str, *, graphs_dir: "Path | None" = None, manifest: dict[str, Any] | None = None
) -> dict[str, Any] | None:
    """The stored ``scaffold`` cache block for an outline (or ``None``)."""
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    if outline_id == DEFAULT_OUTLINE_ID:
        if manifest is None:
            manifest = load_review(name, graphs_dir=base)
        sc = manifest.get("scaffold") if isinstance(manifest, dict) else None
        return sc if isinstance(sc, dict) else None
    rec = _load_sidecar(name, base).get(outline_id)
    if rec is None:
        return None
    return rec.get("scaffold") if isinstance(rec.get("scaffold"), dict) else None

write_draft_cache

write_draft_cache(name: str, outline_id: str, scaffold: dict[str, Any], *, graphs_dir: 'Path | None' = None) -> None

Persist an ADDITIONAL outline's scaffold cache into the sidecar.

The default outline's cache lives in the manifest and is written by :func:zettelkasten.outline._persist_draft — never here.

Source code in zettelkasten/outline_registry.py
def write_draft_cache(
    name: str, outline_id: str, scaffold: dict[str, Any], *, graphs_dir: "Path | None" = None
) -> None:
    """Persist an ADDITIONAL outline's scaffold cache into the sidecar.

    The default outline's cache lives in the manifest and is written by
    :func:`zettelkasten.outline._persist_draft` — never here.
    """
    if outline_id == DEFAULT_OUTLINE_ID:
        raise ValueError("the default outline's scaffold cache lives in the manifest")
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    with review_write_lock(name, graphs_dir=base):
        sidecar = _load_sidecar(name, base)
        rec = sidecar.get(outline_id)
        if rec is None:
            return
        rec["scaffold"] = scaffold
        _persist_sidecar(name, sidecar, base)

list_outlines

list_outlines(name: str, *, graphs_dir: 'Path | None' = None) -> list[dict[str, Any]]

Summaries of every outline (default first, then additional).

Source code in zettelkasten/outline_registry.py
def list_outlines(name: str, *, graphs_dir: "Path | None" = None) -> list[dict[str, Any]]:
    """Summaries of every outline (default first, then additional)."""
    store = ensure_outlines(name, graphs_dir=graphs_dir)
    return [
        {
            "id": o["id"],
            "title": o["title"],
            "question": o["question"],
            "spine": o.get("spine"),
            "spine_mode": o.get("spine_mode"),
            "is_default": o["id"] == DEFAULT_OUTLINE_ID,
            "has_scaffold": bool(o.get("scaffold")),
        }
        for o in store["outlines"].values()
    ]

create_outline

create_outline(name: str, *, title: str = '', question: str = '', outline_id: str | None = None, spine: str | None = None, spine_mode: str | None = None, project: str = '', graph: str = '', graphs_dir: 'Path | None' = None, get_graph: Any = None) -> dict[str, Any]

Create a new ADDITIONAL outline (never the default) under a review.

A fresh structure starts PRISTINE — an empty overlay with no seeded ordering, exactly like :func:zettelkasten.review.create_review. Its structure then reports as unbuilt so the dashboard shows the blank "build a structure" state (and the Structure wizard) instead of the raw auto-projection. (get_graph/project/graph are accepted for call-site compatibility but no longer used at creation.)

Source code in zettelkasten/outline_registry.py
def create_outline(
    name: str,
    *,
    title: str = "",
    question: str = "",
    outline_id: str | None = None,
    spine: str | None = None,
    spine_mode: str | None = None,
    project: str = "",
    graph: str = "",
    graphs_dir: "Path | None" = None,
    get_graph: Any = None,
) -> dict[str, Any]:
    """Create a new ADDITIONAL outline (never the default) under a review.

    A fresh structure starts PRISTINE — an empty overlay with no seeded
    ordering, exactly like :func:`zettelkasten.review.create_review`. Its
    structure then reports as unbuilt so the dashboard shows the blank "build a
    structure" state (and the Structure wizard) instead of the raw
    auto-projection. (``get_graph``/``project``/``graph`` are accepted for
    call-site compatibility but no longer used at creation.)
    """
    validate_id(name, kind="review name", for_filename=True)
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    with review_write_lock(name, graphs_dir=base):
        sidecar = _load_sidecar(name, base)
        base_id = _slug(outline_id or title)
        if base_id == DEFAULT_OUTLINE_ID:  # never shadow the manifest-backed default
            base_id = f"{DEFAULT_OUTLINE_ID}-2"
        oid = base_id
        n = 2
        while oid in sidecar:
            oid = f"{base_id}-{n}"
            n += 1
        overlay = _empty_outline_overlay()
        rec = {
            "id": oid,
            "title": (title or "").strip() or oid,
            "question": (question or "").strip(),
            "spine": _normalize_spine(spine),
            "spine_mode": _normalize_spine_mode(spine_mode),
            "overlay": overlay,
            "scaffold": None,
        }
        sidecar[oid] = rec
        _persist_sidecar(name, sidecar, base)
    return rec

rename_outline

rename_outline(name: str, outline_id: str, title: str, *, graphs_dir: 'Path | None' = None) -> bool

Patch an outline's display title (default → the review title). True if it existed.

Source code in zettelkasten/outline_registry.py
def rename_outline(name: str, outline_id: str, title: str, *, graphs_dir: "Path | None" = None) -> bool:
    """Patch an outline's display title (default → the review title). True if it existed."""
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    clean = (title or "").strip() or outline_id
    with review_write_lock(name, graphs_dir=base):
        if outline_id == DEFAULT_OUTLINE_ID:
            return _patch_manifest(name, base, title=clean)
        sidecar = _load_sidecar(name, base)
        rec = sidecar.get(outline_id)
        if rec is None:
            return False
        rec["title"] = clean
        _persist_sidecar(name, sidecar, base)
    return True

set_outline_question

set_outline_question(name: str, outline_id: str, question: str, *, graphs_dir: 'Path | None' = None) -> bool

Set an outline's research question (default → the review's question).

Source code in zettelkasten/outline_registry.py
def set_outline_question(name: str, outline_id: str, question: str, *, graphs_dir: "Path | None" = None) -> bool:
    """Set an outline's research question (default → the review's question)."""
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    clean = (question or "").strip()
    with review_write_lock(name, graphs_dir=base):
        if outline_id == DEFAULT_OUTLINE_ID:
            return _patch_manifest(name, base, question=clean)
        sidecar = _load_sidecar(name, base)
        rec = sidecar.get(outline_id)
        if rec is None:
            return False
        rec["question"] = clean
        _persist_sidecar(name, sidecar, base)
    return True

set_outline_spine

set_outline_spine(name: str, outline_id: str, spine: str | None, *, spine_mode: str | None = None, graphs_dir: 'Path | None' = None) -> bool

Set an outline's spine org id (default → the review manifest).

Any blank/empty value clears the spine (stored as None). spine_mode is the section-partition mode for an overarching selection; it is stored normalized alongside the spine, and is always cleared to None when the spine itself is cleared (a mode with no spine is meaningless). Returns True if the outline existed.

Source code in zettelkasten/outline_registry.py
def set_outline_spine(
    name: str,
    outline_id: str,
    spine: str | None,
    *,
    spine_mode: str | None = None,
    graphs_dir: "Path | None" = None,
) -> bool:
    """Set an outline's spine org id (default → the review manifest).

    Any blank/empty value clears the spine (stored as ``None``). ``spine_mode`` is
    the section-partition mode for an overarching selection; it is stored
    normalized alongside the spine, and is always cleared to ``None`` when the
    spine itself is cleared (a mode with no spine is meaningless). Returns True if
    the outline existed.
    """
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    clean = _normalize_spine(spine)
    clean_mode = _normalize_spine_mode(spine_mode) if clean else None
    with review_write_lock(name, graphs_dir=base):
        if outline_id == DEFAULT_OUTLINE_ID:
            return _patch_manifest(name, base, spine=clean, spine_mode=clean_mode)
        sidecar = _load_sidecar(name, base)
        rec = sidecar.get(outline_id)
        if rec is None:
            return False
        rec["spine"] = clean
        rec["spine_mode"] = clean_mode
        _persist_sidecar(name, sidecar, base)
    return True

delete_outline

delete_outline(name: str, outline_id: str, *, graphs_dir: 'Path | None' = None) -> bool

Remove an ADDITIONAL outline. The default outline cannot be deleted.

Returns True if removed; False for an unknown id or the default.

Source code in zettelkasten/outline_registry.py
def delete_outline(name: str, outline_id: str, *, graphs_dir: "Path | None" = None) -> bool:
    """Remove an ADDITIONAL outline. The default outline cannot be deleted.

    Returns True if removed; False for an unknown id or the default.
    """
    if outline_id == DEFAULT_OUTLINE_ID:
        return False
    base = Path(graphs_dir) if graphs_dir is not None else GRAPHS_DIR
    with review_write_lock(name, graphs_dir=base):
        sidecar = _load_sidecar(name, base)
        if outline_id not in sidecar:
            return False
        del sidecar[outline_id]
        _persist_sidecar(name, sidecar, base)
        sp = draft_path(name, outline_id, base)
        try:
            if sp.exists():
                sp.unlink()
                _schedule_zettel_commit(str(sp.resolve()))
        except OSError:  # pragma: no cover - defensive
            logger.warning("outline scaffold unlink failed for %s/%s", name, outline_id, exc_info=True)
    return True