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.
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_lock → atomic_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-wide —
tiering, exclusions, favorites: which claims
matter and how, shared by EVERY outline. Always read from the manifest.
- per-outline —
ordering, 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
|