#!/usr/bin/env python3 """ PPT Master - Visualization Catalog Resolver Resolve chart and table references from their live family indexes. Historical ``page_charts`` structure keys remain readable as intent-only compatibility values. They do not resolve to SVG assets and are never part of the live recall catalog. Usage: Import load_visualization_entries() or resolve_visualization_reference(). Examples: from visualization_catalog import resolve_visualization_reference Dependencies: None (only uses the standard library) """ from __future__ import annotations import json import re import sys from dataclasses import dataclass from pathlib import Path from typing import Iterable _SCRIPTS_DIR = Path(__file__).resolve().parent _TEMPLATES_DIR = _SCRIPTS_DIR.parent / "templates" _KEY_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$") _FAMILY_SPECS = { "chart": ("charts", "charts_index.json", "charts"), "table": ("tables", "tables_index.json", "tables"), } VISUALIZATION_SVG_KIND = "visualization-svg" LEGACY_STRUCTURE_INTENT_KIND = "legacy-structure-intent" # Frozen from the 36 Structure entries published in origin/main's historical # broad charts catalog. Keep this exact allowlist: later split-only aliases and # generated Structure keys were never part of that public compatibility input. _LEGACY_STRUCTURE_INTENT_KEYS = frozenset( { "agenda_list", "arc_anchored_list", "chevron_chain_with_tail", "chevron_process", "circular_stages", "client_server_flow", "comparison_columns", "concentric_circles", "fishbone_diagram", "hub_inward_arrows", "hub_spoke", "icon_grid", "isometric_stairs", "journey_map", "kpi_cards", "labeled_card", "layered_architecture", "mind_map", "module_composition", "numbered_steps", "pipeline_with_stages", "process_flow", "pros_cons_chart", "pyramid_chart", "pyramid_isometric", "quadrant_bubble_scatter", "quadrant_text_bullets", "roadmap_vertical", "segmented_wheel", "snake_flow", "team_roster", "timeline", "top_down_tree", "venn_diagram", "vertical_list", "vertical_pillars", } ) class VisualizationCatalogError(RuntimeError): """Reject an unreadable, malformed, missing, or ambiguous reference.""" @dataclass(frozen=True) class VisualizationEntry: """One live SVG entry or one frozen legacy Structure intent.""" family: str key: str summary: str path: Path | None kind: str = VISUALIZATION_SVG_KIND @property def reference(self) -> str: """Return the canonical live ``family/key`` reference.""" if self.kind != VISUALIZATION_SVG_KIND: raise VisualizationCatalogError( f"legacy Structure intent {self.key!r} has no canonical reference" ) return f"{self.family}/{self.key}" @property def display_path(self) -> str: """Return the Skill-relative SVG path.""" if self.path is None: raise VisualizationCatalogError( f"{self.key!r} is a legacy Structure intent without an SVG path" ) return self.path.relative_to(_SCRIPTS_DIR.parent).as_posix() @dataclass(frozen=True) class VisualizationCatalog: """Canonical entries plus family-local compatibility aliases.""" entries: dict[str, VisualizationEntry] aliases: dict[str, str] def visualization_families() -> tuple[str, ...]: """Return the stable live visualization family order.""" return tuple(_FAMILY_SPECS) def legacy_structure_intent_keys() -> tuple[str, ...]: """Return the frozen legacy ``page_charts`` Structure bare keys.""" return tuple(sorted(_LEGACY_STRUCTURE_INTENT_KEYS)) def _normalize_families(families: Iterable[str] | None) -> tuple[str, ...]: requested = visualization_families() if families is None else tuple(families) normalized: list[str] = [] for raw_family in requested: family = str(raw_family).strip().casefold() if family not in _FAMILY_SPECS: allowed = ", ".join(visualization_families()) raise VisualizationCatalogError( f"unknown visualization family {raw_family!r}; expected one of {allowed}" ) if family not in normalized: normalized.append(family) if not normalized: raise VisualizationCatalogError("at least one visualization family is required") return tuple(normalized) def _load_family(family: str) -> tuple[dict[str, VisualizationEntry], dict[str, str]]: directory_name, index_name, object_key = _FAMILY_SPECS[family] family_dir = _TEMPLATES_DIR / directory_name index_path = family_dir / index_name try: payload = json.loads(index_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise VisualizationCatalogError( f"cannot read {family} catalog {index_path}: {exc}" ) from exc if not isinstance(payload, dict): raise VisualizationCatalogError(f"{family} catalog root must be an object") raw_entries = payload.get(object_key) if not isinstance(raw_entries, dict): raise VisualizationCatalogError( f"{family} catalog {index_path} has no '{object_key}' object" ) entries: dict[str, VisualizationEntry] = {} for raw_key, raw_item in raw_entries.items(): if ( not isinstance(raw_key, str) or _KEY_RE.fullmatch(raw_key) is None or not isinstance(raw_item, dict) ): raise VisualizationCatalogError( f"{family} catalog entry {raw_key!r} is malformed" ) summary = raw_item.get("summary") if not isinstance(summary, str) or not summary.strip(): raise VisualizationCatalogError( f"{family} catalog entry {raw_key!r} has no non-empty summary" ) entry = VisualizationEntry( family=family, key=raw_key, summary=summary.strip(), path=(family_dir / f"{raw_key}.svg").resolve(), ) entries[entry.reference] = entry raw_aliases = payload.get("aliases", {}) if not isinstance(raw_aliases, dict): raise VisualizationCatalogError(f"{family} catalog aliases must be an object") aliases: dict[str, str] = {} for raw_alias, raw_target in raw_aliases.items(): if ( not isinstance(raw_alias, str) or _KEY_RE.fullmatch(raw_alias) is None or not isinstance(raw_target, str) or _KEY_RE.fullmatch(raw_target) is None ): raise VisualizationCatalogError( f"{family} catalog alias {raw_alias!r} is malformed" ) target_reference = f"{family}/{raw_target}" if raw_alias in raw_entries: raise VisualizationCatalogError( f"{family} alias {raw_alias!r} collides with a canonical key" ) if target_reference not in entries: raise VisualizationCatalogError( f"{family} alias {raw_alias!r} targets missing key {raw_target!r}" ) aliases[f"{family}/{raw_alias}"] = target_reference return entries, aliases def load_visualization_catalog( families: Iterable[str] | None = None, ) -> VisualizationCatalog: """Load selected live family registries.""" selected = _normalize_families(families) entries: dict[str, VisualizationEntry] = {} aliases: dict[str, str] = {} for family in selected: family_entries, family_aliases = _load_family(family) entries.update(family_entries) aliases.update(family_aliases) if not entries: shown = ", ".join(selected) raise VisualizationCatalogError( f"no visualization entries are available for family selection {shown}" ) return VisualizationCatalog(entries=entries, aliases=aliases) def load_visualization_entries( families: Iterable[str] | None = None, ) -> dict[str, VisualizationEntry]: """Return canonical entries keyed by ``family/key``.""" return load_visualization_catalog(families).entries def _require_svg(entry: VisualizationEntry) -> VisualizationEntry: if entry.kind != VISUALIZATION_SVG_KIND: raise VisualizationCatalogError( f"{entry.family}/{entry.key} has unsupported kind {entry.kind!r}" ) if entry.path is None: raise VisualizationCatalogError(f"{entry.reference!r} has no SVG asset path") if entry.path.suffix.casefold() != ".svg" or not entry.path.is_file(): raise VisualizationCatalogError( f"{entry.reference!r} has no SVG asset at {entry.path}" ) return entry def resolve_visualization_reference( value: str, *, allow_legacy_bare: bool = False, ) -> VisualizationEntry: """Resolve one live ``family/key`` or supported legacy bare key. Legacy Structure intents are accepted only when ``allow_legacy_bare`` is true. A qualified ``structure/`` is never a live reference. """ normalized = str(value).strip().casefold() if not normalized: raise VisualizationCatalogError("visualization reference must not be empty") catalog = load_visualization_catalog() if "/" in normalized: family, separator, key = normalized.partition("/") if ( not separator or family not in _FAMILY_SPECS or _KEY_RE.fullmatch(key) is None or "/" in key ): raise VisualizationCatalogError( f"invalid canonical visualization reference {value!r}" ) reference = f"{family}/{key}" entry = catalog.entries.get(reference) if entry is None: if reference in catalog.aliases: raise VisualizationCatalogError( f"{value!r} is a legacy alias; use {catalog.aliases[reference]!r}" ) raise VisualizationCatalogError( f"canonical visualization reference {value!r} is not registered" ) return _require_svg(entry) if not allow_legacy_bare: raise VisualizationCatalogError( f"{value!r} must use canonical family/key grammar" ) if _KEY_RE.fullmatch(normalized) is None: raise VisualizationCatalogError(f"invalid legacy visualization key {value!r}") matches = [ entry for entry in catalog.entries.values() if entry.key == normalized ] for alias_reference, target_reference in catalog.aliases.items(): _, alias = alias_reference.split("/", 1) if alias == normalized: matches.append(catalog.entries[target_reference]) if normalized in _LEGACY_STRUCTURE_INTENT_KEYS: matches.append( VisualizationEntry( family="structure", key=normalized, summary=( "Frozen legacy page_charts Structure intent; author the " "page structure from its semantic relationships." ), path=None, kind=LEGACY_STRUCTURE_INTENT_KIND, ) ) unique_matches = { (entry.kind, entry.family, entry.key): entry for entry in matches } if not unique_matches: raise VisualizationCatalogError( f"legacy visualization key {value!r} is not registered" ) if len(unique_matches) > 1: candidates = ", ".join( f"{family}/{key} ({kind})" for kind, family, key in sorted(unique_matches) ) raise VisualizationCatalogError( f"legacy visualization key {value!r} is ambiguous across {candidates}" ) resolved = next(iter(unique_matches.values())) if resolved.kind == LEGACY_STRUCTURE_INTENT_KIND: if resolved.path is not None: raise VisualizationCatalogError( f"legacy Structure intent {resolved.key!r} must not have an asset path" ) return resolved return _require_svg(resolved) if __name__ == "__main__": from console_encoding import configure_utf8_stdio configure_utf8_stdio() print( "Use visualization_catalog via visualization_recall.py or project_manager.py.", file=sys.stderr, ) raise SystemExit(2)