Skip to content

zettelkasten.outlines

zettelkasten.outlines

Backward-compatible shim: outlines was renamed to outline_registry.

The multi-outline registry now lives in :mod:zettelkasten.outline_registry. This module re-exports its full surface so existing importers (from zettelkasten import outlines) keep working unchanged.

validate_id

validate_id(value: str, *, kind: str = 'id', for_filename: bool = False) -> str

Validate that value is safe to interpolate into a filesystem path.

By default this is a containment check, not a format check. It rejects empty values, path separators, .. sequences, absolute paths, control characters, and over-long strings -- but otherwise permits any characters (letters, digits, -, _, ., : for namespaced IDs, etc.) so existing IDs keep working.

When for_filename=True the check is stricter: it additionally rejects Windows-illegal filename characters (including :, which would otherwise create an NTFS alternate data stream), trailing dots/spaces (silently stripped by Windows), and reserved device names. Use this mode at sites that turn the value directly into a filename.

Returns the value unchanged when valid; raises :class:ValueError otherwise.

Source code in memory/safety.py
def validate_id(value: str, *, kind: str = "id", for_filename: bool = False) -> str:
    """Validate that ``value`` is safe to interpolate into a filesystem path.

    By default this is a *containment* check, not a format check. It rejects
    empty values, path separators, ``..`` sequences, absolute paths, control
    characters, and over-long strings -- but otherwise permits any characters
    (letters, digits, ``-``, ``_``, ``.``, ``:`` for namespaced IDs, etc.) so
    existing IDs keep working.

    When ``for_filename=True`` the check is stricter: it additionally rejects
    Windows-illegal filename characters (including ``:``, which would otherwise
    create an NTFS alternate data stream), trailing dots/spaces (silently
    stripped by Windows), and reserved device names. Use this mode at sites that
    turn the value directly into a filename.

    Returns the value unchanged when valid; raises :class:`ValueError` otherwise.
    """
    if not isinstance(value, str):
        raise ValueError(f"{kind} must be a string, got {type(value).__name__}")
    if not value:
        raise ValueError(f"{kind} must not be empty")
    if len(value) > _MAX_ID_LEN:
        raise ValueError(f"{kind} is too long ({len(value)} > {_MAX_ID_LEN} chars)")
    for ch in _ID_FORBIDDEN_CHARS:
        if ch in value:
            raise ValueError(f"{kind} must not contain {ch!r}")
    if ".." in value:
        raise ValueError(f"{kind} must not contain '..'")
    if value in (".", ".."):
        raise ValueError(f"{kind} must not be a relative path component")
    if any(ord(c) < 32 for c in value):
        raise ValueError(f"{kind} must not contain control characters")
    if os.path.isabs(value):
        raise ValueError(f"{kind} must not be an absolute path")

    if for_filename:
        for ch in _WINDOWS_ILLEGAL_FILENAME_CHARS:
            if ch in value:
                raise ValueError(
                    f"{kind} must not contain {ch!r} when used as a filename"
                )
        if value[-1] in (" ", "."):
            raise ValueError(f"{kind} must not end with a space or '.'")
        stem = value.split(".", 1)[0].lower()
        if stem in _WINDOWS_RESERVED_NAMES:
            raise ValueError(f"{kind} must not be a reserved device name")

    return value

atomic_write_text

atomic_write_text(path: Path, text: str) -> None

Write text to path crash-safely (temp file + fsync + rename).

The temp file is created in the SAME directory as path so the final os.replace is an atomic rename on the same filesystem. The file's contents are flushed and os.fsync'd before the rename, and the PARENT DIRECTORY is fsync'd after, so a crash can never leave a half-written manifest/note nor lose the directory entry of a newly-created file. The temp file is cleaned up on failure. Mirrors the temp+rename precedent in config.write_config and embeddings.save_cache, adding the fsyncs for durability.

Source code in zettelkasten/graph_io.py
def atomic_write_text(path: Path, text: str) -> None:
    """Write ``text`` to ``path`` crash-safely (temp file + fsync + rename).

    The temp file is created in the SAME directory as ``path`` so the final
    ``os.replace`` is an atomic rename on the same filesystem. The file's
    contents are flushed and ``os.fsync``'d before the rename, and the PARENT
    DIRECTORY is fsync'd after, so a crash can never leave a half-written
    manifest/note nor lose the directory entry of a newly-created file. The temp
    file is cleaned up on failure. Mirrors the temp+rename precedent in
    ``config.write_config`` and ``embeddings.save_cache``, adding the fsyncs for
    durability.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
    try:
        with open(tmp, "w", encoding="utf-8") as fh:
            fh.write(text)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, path)
        # Best-effort: fsync the parent directory so the new directory entry
        # created by the rename is itself durable. Windows cannot fsync a
        # directory (and O_RDONLY on a dir may fail there), so swallow any error
        # — this is a pure no-op on those platforms and must never raise.
        try:
            dir_fd = os.open(str(path.parent), os.O_RDONLY)
            try:
                os.fsync(dir_fd)
            finally:
                os.close(dir_fd)
        except (OSError, AttributeError):
            pass
    except Exception:
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        raise

load_review

load_review(name: str, graphs_dir: Path | None = None) -> dict

Load a single literature-review manifest from _reviews/.yaml.

Returns {} for a missing file (no exception), mirroring :func:load_project. _reviews/ lives beside _projects/ at <repo>/.zettelkasten/.

A corrupt manifest (invalid YAML, non-UTF-8 bytes, unreadable inode) is NOT silently swallowed: unlike the missing case it would mask real data loss, so it is re-raised as a clear, catchable ValueError that the route layer can map to a clean status instead of a raw 500 traceback.

Source code in zettelkasten/graph_io.py
def load_review(name: str, graphs_dir: Path | None = None) -> dict:
    """Load a single literature-review manifest from _reviews/<name>.yaml.

    Returns ``{}`` for a missing file (no exception), mirroring
    :func:`load_project`. ``_reviews/`` lives beside ``_projects/`` at
    ``<repo>/.zettelkasten/``.

    A *corrupt* manifest (invalid YAML, non-UTF-8 bytes, unreadable inode) is
    NOT silently swallowed: unlike the missing case it would mask real data
    loss, so it is re-raised as a clear, catchable ``ValueError`` that the route
    layer can map to a clean status instead of a raw 500 traceback.
    """
    base = graphs_dir or _graphs_dir()
    validate_id(name, kind="review name", for_filename=True)
    filepath = safe_join(base / "_reviews", f"{name}.yaml")
    if not filepath.exists():
        return {}
    try:
        data = yaml.safe_load(filepath.read_text(encoding="utf-8"))
    except (yaml.YAMLError, UnicodeDecodeError, OSError) as exc:
        logger.warning("Corrupt review manifest %s: %s", filepath, exc)
        raise ValueError(f"corrupt review manifest: {name}") from exc
    return data if isinstance(data, dict) else {}

write_yaml_atomic

write_yaml_atomic(path: Path, data) -> None

Dump data to YAML and write it atomically via :func:atomic_write_text.

Uses default_flow_style=False, sort_keys=False to match the existing manifest/meta YAML style in this package.

Source code in zettelkasten/graph_io.py
def write_yaml_atomic(path: Path, data) -> None:
    """Dump ``data`` to YAML and write it atomically via :func:`atomic_write_text`.

    Uses ``default_flow_style=False, sort_keys=False`` to match the existing
    manifest/meta YAML style in this package.
    """
    atomic_write_text(
        path,
        yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
    )

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