Ryan Malloy 8abd7719bf Initial SpiceBook MVP: notebook interface for circuit simulation
Phase 1 implementation with ngspice backend and Astro/React frontend:

Backend (FastAPI):
- ngspice subprocess engine with custom .raw file parser
- Notebook CRUD with .spicebook JSON format (filesystem storage)
- Simulation endpoints (standalone + cell-in-notebook)
- SVG waveform export endpoint
- 18 REST API routes, 5 passing tests

Frontend (Astro 5 + React 19):
- Notebook editor as React island with Zustand state management
- CodeMirror 6 with custom SPICE language mode (syntax highlighting
  for dot commands, components, engineering notation, comments)
- uPlot waveform viewer with transient and AC/Bode plot modes
- Markdown cells with edit/preview toggle
- Notebook list with card grid UI
- Dark theme, Tailwind CSS 4, Lucide icons

Infrastructure:
- Docker Compose with dev/prod targets
- Caddy-based frontend prod serving
- 3 example notebooks (RC filter, voltage divider, CE amplifier)
2026-02-13 01:44:38 -07:00

148 lines
4.3 KiB
Python

"""File-based notebook storage using .spicebook (JSON) files."""
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from spicebook.models.notebook import (
Cell,
CellType,
Notebook,
NotebookMetadata,
NotebookSummary,
)
logger = logging.getLogger(__name__)
SPICEBOOK_EXT = ".spicebook"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _slugify(text: str) -> str:
"""Convert text to a filesystem-safe slug."""
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s_]+", "-", slug)
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or "notebook"
def _generate_notebook_id(title: str) -> str:
"""Generate a notebook ID from title, with short UUID suffix for uniqueness."""
slug = _slugify(title)
short_uid = uuid.uuid4().hex[:8]
return f"{slug}-{short_uid}"
def _scan_directory(directory: Path) -> list[Path]:
"""Find all .spicebook files in a directory (non-recursive)."""
if not directory.is_dir():
return []
return sorted(directory.glob(f"*{SPICEBOOK_EXT}"))
def _notebook_id_from_path(path: Path) -> str:
return path.stem
def list_notebooks(directory: Path) -> list[NotebookSummary]:
"""List all notebooks in the directory and its examples/ subdirectory."""
summaries: list[NotebookSummary] = []
seen_ids: set[str] = set()
# Scan user notebooks first, then examples
for scan_dir in [directory / "user", directory / "examples"]:
for path in _scan_directory(scan_dir):
nb_id = _notebook_id_from_path(path)
if nb_id in seen_ids:
continue
seen_ids.add(nb_id)
try:
nb = _read_notebook_file(path)
summaries.append(NotebookSummary(
id=nb_id,
title=nb.metadata.title,
engine=nb.metadata.engine,
tags=nb.metadata.tags,
cell_count=len(nb.cells),
modified=nb.metadata.modified,
))
except Exception:
logger.warning("Skipping corrupt notebook: %s", path)
return summaries
def load_notebook(directory: Path, notebook_id: str) -> Notebook | None:
"""Load a notebook by ID, searching user/ then examples/ subdirectories."""
for sub in ["user", "examples"]:
path = directory / sub / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
return _read_notebook_file(path)
return None
def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None:
"""Save a notebook to the user/ subdirectory (updates modified timestamp)."""
user_dir = directory / "user"
user_dir.mkdir(parents=True, exist_ok=True)
notebook.metadata.modified = _now_iso()
path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}"
path.write_text(
notebook.model_dump_json(indent=2),
encoding="utf-8",
)
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
"""Create a new notebook and save it. Returns (notebook_id, notebook)."""
nb_id = _generate_notebook_id(title)
now = _now_iso()
notebook = Notebook(
metadata=NotebookMetadata(
title=title,
engine=engine,
created=now,
modified=now,
),
cells=[
Cell(
id=f"cell-{uuid.uuid4().hex[:12]}",
type=CellType.MARKDOWN,
source=f"# {title}\n\nAdd SPICE cells below to begin simulating.",
),
],
)
save_notebook(directory, nb_id, notebook)
return nb_id, notebook
def delete_notebook(directory: Path, notebook_id: str) -> bool:
"""Delete a notebook file. Only allows deleting from user/ subdirectory.
Returns True if deleted, False if not found.
"""
path = directory / "user" / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
path.unlink()
return True
return False
def _read_notebook_file(path: Path) -> Notebook:
"""Read and validate a .spicebook JSON file."""
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
return Notebook.model_validate(data)