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)
148 lines
4.3 KiB
Python
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)
|