diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..b37841b --- /dev/null +++ b/README.es.md @@ -0,0 +1,959 @@ +
+ +identidad lablog + +# lablog + +**Bitácora de laboratorio LaTeX en vivo para científicos en activo** + +*Proyecto Pharos · José Labarca Baeza · Universidad Técnica Federico Santa María · Valparaíso* + +
+ +hero académico lablog + +
+ +

+ versión PyPI + versiones de Python + descargas PyPI + CI + Pages + Release + último commit + issues + PRs + stars + forks + languages + code size + coverage + licencia + estado del proyecto +

+ +

+ English + Español + sitio de documentación + instalar desde PyPI + v0.3.0 +

+ + + Acerca de + · Galería + · Funcionalidades + · Arquitectura + · Atajos + · Instalación + · Tutoriales + · CLI + · Seguridad + · Citar + · English + + +
+ +--- + +## Tabla de contenidos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1. Acerca de2. Galería3. Funcionalidades4. Arquitectura
5. Stack6. Instalación7. Inicio rápido8. Tutoriales
9. CLI10. API HTTP11. Diagramas12. Atajos
13. Laboratorio14. PDF15. Bóveda16. Exportar
17. Config18. Datos19. Seguridad20. Pruebas
21. Pages22. Roadmap23. Citar24. Licencia
+ +--- + +## Acerca de + +> **lablog** es una bitácora de laboratorio de grado de investigación que vive donde ocurre +> el experimento. Combina un editor LaTeX estructural con vista previa en vivo, celdas +> ejecutables, diagramas parametrizados, dictado por voz e historial inmutable por event +> sourcing, de modo que el registro de una investigación pueda reconstruirse tal como se produjo. + +A diferencia de un procesador usado *después* del experimento, lablog está pensado para +correr **mientras el trabajo avanza** — instrumento abierto, notas incompletas, valores +aún en movimiento. Premisa: escribir el paper no debería separarse de producir los datos. + +| Herramienta | Cuándo se usa habitualmente | +| :--- | :--- | +| Overleaf | Tras el experimento, al preparar el manuscrito. | +| TeXstudio / TeXmacs | Autoría LaTeX clásica en escritorio. | +| Jupyter / JupyterLab | Notebooks computacionales; la prosa es secundaria. | +| **lablog** | **Durante** el experimento: dictar, ejecutar, parametrizar diagramas y preservar. | + +### Principios de diseño + +1. **Local-first.** Bind por defecto a loopback. Las notas viven en `LABLOG_DATA_DIR`. +2. **Event sourcing, no mutación silenciosa.** Las escrituras anexan eventos inmutables; el estado es proyección pura. +3. **Preview aproximada + PDF fiel.** KaTeX para velocidad; Tectonic para verdad tipográfica. +4. **Peso opcional.** El núcleo es liviano; voz, escritorio y PySpice son extras. +5. **Seguridad como corrección.** Path traversal, shell-escape, límites de tamaño y OCC son invariantes, no parches a posteriori. + +### Estado + +| Ítem | Valor | +| :--- | :--- | +| Distribución | [`jose-labarca-lablog`](https://pypi.org/project/jose-labarca-lablog/) en PyPI | +| Versión actual | **v0.3.0** ([notas](docs/release-notes-v0.3.0.md)) | +| Licencia | MIT | +| Lenguaje principal (motor) | Python 3.11+ | +| Lenguaje principal (UI) | TypeScript / React 19 | +| Mantenedor | José Labarca Baeza | + +--- + +## Galería + +Capturas reales de una instancia en ejecución (Vite + FastAPI, dark theme, v0.3.x). Script de regeneración: [`scripts/capture_ui_screenshots.mjs`](scripts/capture_ui_screenshots.mjs). + +
+ +### Mesa de trabajo + +Mesa de trabajo principal con editor y preview + +Figure 1. Shell principal: grupos de proyecto, editor LaTeX estructural, vista previa (\section, equation, % lablog-param). + +

+ +### Presets de diagramas + +Panel de diagramas con presets de circuitos + +Figure 2. Banco de diagramas: presets de circuitos / control / óptica con Insertar, +Sim y SPICE. + +

+ +### Parámetros + +Panel de parámetros con sliders + +Figure 3. Panel de parámetros: valores, resaltado dual, reaplicar diagrama / reaplicar + sim. + +

+ +### Modo laboratorio + +Modo laboratorio con celda Python + +Figure 4. Modo laboratorio: layout denso de celdas, fuente Python, controles de ejecución. + +

+ +### Preferencias y atajos de teclado + +Preferencias con atajos de teclado + +Figure 5. Preferencias: fuente del editor, paletas, color de acento y chords globales editables (mod+…). + +

+ +### Vista de ajustes + +Vista general de preferencias + +Figure 6. Superficie completa de preferencias (densidad, movimiento, layout laboratorio, import/export JSON). + +

+ +### Panel de celdas + +Documento con celda python y parámetros + +Figure 7. Documento con \begin{python} y parámetros abiertos para reaplicar. + +

+ +### Identidad y arquitectura + +lablog graphic kit +  +Architecture illustration + +Figure 8. Kit de identidad e ilustración de arquitectura por capas. + +
+ +--- + +## Funcionalidades + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MóduloCapacidad
Render LaTeX estructuralSecciones, énfasis, listas, hipervínculos y matemática inline en la prosa. KaTeX para entornos de display (align, equation, gather, cases, pmatrix) con numeración automática de ecuaciones.
EditorGutter de líneas, overlay de parámetros, auto-guardado con OCC, buscar y reemplazar, undo/redo que sobrevive a inserts programáticos, wrap inteligente de delimitadores, snippets y símbolos en el cursor.
Dictado por vozSpeechRecognition del navegador con parseo de intent, más Whisper local opcional (extra [voice]). Timeout de sesión para evitar reconocedores colgados.
Celdas ejecutables\begin{python}...\end{python} (y celdas de lab mode) corren en un kernel Jupyter real. Stdout, resultados y figuras vuelven al documento; el kernel se interrumpe con timeout.
BóvedaAdjunta imágenes, CSV, PDF, DOCX, audio y scripts; preview in situ. Meta atómica; solo basename; techo de 100 MB; borrado diferido.
Historial event-sourcedCada edición, ejecución y adjunto es JSONL append-only. El UI de time-travel recorre y restaura cualquier índice de evento con OCC consciente de versión en el cliente.
Banco de diagramasDoce presets parametrizados (circuitos, control, mecánica, óptica, Feynman). Reaplicar sin {{placeholders}} vía % lablog-param. Highlight dual (línea del editor + color Circuitikz). Celdas PySpice opcionales con fallback numpy.
PersonalizaciónDensity, editor font, Nord palette, reduced motion, profiles (Laboratory / Paper / Teaching), exportable preferences JSON, configurable keyboard shortcuts.
Exportación.tex, texto plano, HTML, PDF, DOCX (pandoc), Jupyter .ipynb, sitio estático para GitHub Pages, HTML listo para Canva. Títulos escapados para LaTeX.
EscritorioVentana nativa vía pywebview ([desktop]); bundle portable PyInstaller para distribución offline.
+ +--- + +## Arquitectura + +
+Architecture layers +
+ +```mermaid +%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6', 'secondaryColor':'#48484A', 'tertiaryColor':'#1C1C1E' }}}%% +flowchart TB + subgraph Client["Client (browser or pywebview)"] + UI["React 19 · Vite 8 · Zustand"] + ED["LaTeX editor · OCC autosave"] + PV["KaTeX preview · PDF panel"] + LB["Lab canvas · cells"] + DG["Diagrams · parameters"] + end + + subgraph Motor["Motor (FastAPI · localhost)"] + API["REST /api/v1"] + CMD["commands.py"] + PRJ["projector · projections"] + AST["latex_ast · serialize"] + KER["code_engine · Jupyter"] + PDF["pdf_engine · Tectonic"] + VLT["vault"] + DIA["diagrams · expand · pyspice"] + end + + subgraph Disk["LABLOG_DATA_DIR"] + EV["events/*.jsonl"] + FIG["figures/"] + VAULT["vault/"] + BIN["bin/tectonic"] + SITE["site/ export"] + end + + UI --> API + ED --> API + LB --> API + DG --> API + API --> CMD + CMD --> EV + PRJ --> EV + KER --> FIG + VLT --> VAULT + PDF --> BIN + API --> SITE +``` + +### Event sourcing + +```mermaid +%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6' }}}%% +sequenceDiagram + participant UI as UI / CLI + participant API as FastAPI + participant ES as EventStore + participant P as Projector + + UI->>API: PUT /pages/{id} raw + version + API->>ES: append(document_replaced, expected_version) + alt version mismatch + ES-->>API: VersionConflictError + API-->>UI: 409 VERSION_CONFLICT + current + else ok + ES-->>API: fsync JSONL line + API->>P: project(events) + P-->>API: PageDetail + API-->>UI: latex, ast, version, project_id + end +``` + +**Reglas aplicadas en el código y en CONTRIBUTING:** + +1. Nunca mutar el AST proyectado desde la API; anexar un evento y re-proyectar. +2. Rutas solo a través de `src/lablog/config.py`. +3. Formas de respuesta reflejadas en `ui/src/lib/api.ts`. +4. I/O de red fuera de componentes hoja cuando sea posible (Zustand + hooks). +5. Diagramas: presets en `catalog.py`, clamp/expand en `expand.py`, SPICE opcional en `pyspice_sim.py`. + +
+Módulo map (source tree) + +
+ +| Ruta | Responsabilidad | +| :--- | :--- | +| [`src/lablog/api.py`](src/lablog/api.py) | Superficie HTTP, OCC en replace, bóveda, diagramas, export. | +| [`src/lablog/event_store.py`](src/lablog/event_store.py) | JSONL append-only; lock por página; append condicional (`expected_version`). | +| [`src/lablog/events.py`](src/lablog/events.py) | Tipos de evento y constructores. | +| [`src/lablog/projector.py`](src/lablog/projector.py) | Fold puro de eventos al estado de página. | +| [`src/lablog/projections.py`](src/lablog/projections.py) | Modelos de lectura: detail, summary, history, cells. | +| [`src/lablog/commands.py`](src/lablog/commands.py) | Comandos de dominio (create, replace, cells, restore). | +| [`src/lablog/latex_ast.py`](src/lablog/latex_ast.py) | Parse / serialización del árbol del documento. | +| [`src/lablog/code_engine.py`](src/lablog/code_engine.py) | Kernel Jupyter con interrupt por timeout. | +| [`src/lablog/vault.py`](src/lablog/vault.py) | Adjuntos, meta atómica, programación de borrado. | +| [`src/lablog/exporter.py`](src/lablog/exporter.py) | Export de sitio estático y notebook. | +| [`src/lablog/pdf_engine.py`](src/lablog/pdf_engine.py) | Install/warm/compile Tectonic y mapeo de errores. | +| [`src/lablog/diagrams/`](src/lablog/diagrams/) | Presets, expand, highlight, PySpice / numpy. | +| [`src/lablog/cli.py`](src/lablog/cli.py) | Punto de entrada `lablog`. | +| [`ui/src/stores/app-store.ts`](ui/src/stores/app-store.ts) | Estado del cliente, preferencias, hooks de flush. | +| [`ui/src/hooks/use-page-update.ts`](ui/src/hooks/use-page-update.ts) | Autosave con debounce, PUT serializado, reintento 409. | +| [`ui/src/components/editor/latex-editor.tsx`](ui/src/components/editor/latex-editor.tsx) | Superficie del editor. | +| [`ui/src/components/lab/lab-canvas.tsx`](ui/src/components/lab/lab-canvas.tsx) | Modo lab; flush de celdas dirty al salir. | + +
+ +--- + +## Stack + +| Capa | Tecnologías | +| :--- | :--- | +| Motor | Python 3.11+, FastAPI, Pydantic v2, Jupyter Client, optional faster-whisper / PySpice | +| Persistencia | JSONL event log, atomic renames for vault meta, deterministic projection | +| Interfaz | React 19, TypeScript, Vite 8, Tailwind CSS v4, Zustand, shadcn/ui, Radix | +| Matemática | KaTeX (preview); Tectonic / XeTeX (PDF) | +| Herramientas | uv, npm, Ruff, Mypy (strict), oxlint, pytest (≥80% cov), Vitest, Playwright (smoke), pre-commit, GitHub Accións | + +--- + +## Instalación + +### Desde PyPI (recomendado) + +```bash +pip install -U jose-labarca-lablog +lablog serve +``` + +Abre la UI servida por el motor (la rueda incluye `ui/dist` compilado), o apunta un +front de desarrollo a la API. + +| Extra | Instalación | Propósito | +| :--- | :--- | :--- | +| Escritorio | `pip install "jose-labarca-lablog[desktop]"` | Ventana nativa (`lablog app`) | +| Voz offline | `pip install "jose-labarca-lablog[voice]"` | Whisper local (descarga grande) | +| SPICE | `pip install "jose-labarca-lablog[pyspice]"` | Celdas PySpice (requiere `ngspice` en PATH) | +| Dev | `pip install "jose-labarca-lablog[dev]"` | pytest, ruff, mypy, bandit, pre-commit | + +### Desde el código fuente + +> **Requisitos.** Python ≥ 3.11, Node 22, [uv](https://docs.astral.sh/uv/), `npm`. +> Opcional: `pandoc` (+ TeX) para DOCX/PDF vía pandoc; Tectonic lo gestiona lablog para el PDF in-app. + +```bash +git clone https://github.com/kegouro/lablog.git +cd lablog +uv sync --extra dev +source .venv/bin/activate +cp .env.example .env + +cd ui && npm install && cd .. +``` + +```bash +# Optional extras +uv sync --extra desktop +uv sync --extra voice +uv sync --extra pyspice +``` + +--- + +## Inicio rápido + +### Desarrollo (dos procesos) + +```bash +# Terminal A — API +source .venv/bin/activate +uvicorn lablog.api:app --host 127.0.0.1 --port 8000 --reload + +# Terminal B — UI +cd ui && npm run dev +``` + +| Surface | URL | +| :--- | :--- | +| UI (Vite) | http://127.0.0.1:5173 | +| API | http://127.0.0.1:8000/api/v1 | +| Health | http://127.0.0.1:8000/api/v1/health | +| OpenAPI | http://127.0.0.1:8000/docs | + +### Un proceso (tipo producción) + +```bash +cd ui && npm run build && cd .. +uvicorn lablog.api:app --host 127.0.0.1 --port 8000 +# or +lablog serve --host 127.0.0.1 --port 8000 +``` + +### Escritorio + +```bash +uv sync --extra desktop +cd ui && npm run build && cd .. +lablog app +``` + +### Smoke de una línea (solo API) + +```bash +curl -s http://127.0.0.1:8000/api/v1/health | python -m json.tool +``` + +--- + +## Tutoriales + +### Tutorial 1 — Primera página por CLI + +```bash +source .venv/bin/activate + +# Crear una página vacía +lablog create-page --title "RC lab session" + +# O sembrar desde una plantilla de física +lablog new --title "Optics notes" --template article_physics + +lablog list-pages +lablog render # print projected LaTeX +lablog events # inspect JSONL history +``` + +### Tutorial 2 — Circuito RC parametrizado en la UI + +1. Arrancar API + UI (Inicio rápido). +2. Crear una página desde la barra lateral. +3. Abrir **Diagramas** → **RC serie — carga**. +4. Insertar diagrama (o insertar + celda de simulación). +5. Abrir **Parámetros**: ajustar `R`, `C`, `V0`. +6. Reaplicar; confirmar comentarios `% lablog-param` y actualización TikZ. +7. Enfocar un parámetro: resaltado dual (línea del gutter + `color=` en Circuitikz). +8. Exportar **Notebook Jupyter (.ipynb)** o **Compilar PDF**. + +```bash +# Expansión equivalente por CLI +lablog diagrams list +lablog diagrams expand rc_series_charge --json +``` + +### Tutorial 3 — Celda ejecutable y figura + +En el editor (o modo Laboratorio): + +```latex +\begin{python}[label=demo] +import numpy as np +import matplotlib.pyplot as plt +t = np.linspace(0, 5, 200) +plt.plot(t, np.exp(-t)) +plt.xlabel("t") +plt.ylabel("e^{-t}") +\end{python} +``` + +Ejecuta la celda desde el panel **Celdas** o el canvas de Lab. Salida y figuras se guardan en +`LABLOG_DATA_DIR/figures//` y se proyectan en el AST. + +### Tutorial 4 — Time travel y OCC + +1. Edita la página varias veces (autosave ~300 ms de inactividad). +2. Abre historial / time-travel; recorre el índice de eventos; restaura una versión pasada. +3. Ediciones concurrentes: el cliente envía `version`; en conflicto la API responde `409` con + `error_code: VERSION_CONFLICT` y `current`. El UI reintenta una vez o reencola el draft. + +### Tutorial 5 — Sitio estático para GitHub Pages + +```bash +source .venv/bin/activate +# Preferible versionar datos dentro del repo para notas públicas: +# LABLOG_DATA_DIR=./data + +python - <<'PY' +from lablog.exporter import export_site +print(export_site()) +PY +``` + +Enable **Settings → Pages → GitHub Accións** on a fork; [`.github/workflows/pages.yml`](.github/workflows/pages.yml) +publishes on push to `main`. + +--- + +## Referencia CLI + +```text +lablog {create-page,new,list-pages,append-text,render,events,serve,app,diagrams} +``` + +| Comando | Propósito | +| :--- | :--- | +| `create-page` | Crear una página (título / proyecto). | +| `new` | Crear página; `--template` opcional. | +| `list-pages` | Listar resúmenes de páginas. | +| `append-text` | Anexar evento de texto. | +| `render` | Proyectar e imprimir LaTeX. | +| `events` | Volcar el log de eventos de una página. | +| `serve` | API con uvicorn (sirve UI si está build). | +| `app` | Shell de escritorio (`[desktop]`). | +| `diagrams list` | Catálogo de presets. | +| `diagrams expand ` | Expandir TikZ (+ params) a stdout / JSON. | + +Ejemplos: + +```bash +lablog serve --host 127.0.0.1 --port 8000 +lablog diagrams list +lablog diagrams expand thin_lens --set f=0.1 --set do=0.3 +``` + +--- + +## Superficie HTTP + +Ruta base: **`/api/v1`**. Esquema interactivo: `/docs` (Swagger) con el servidor en marcha. + +
+Core resources (summary) + +
+ +| Method | Path | Notas | +| :--- | :--- | :--- | +| GET | `/health` | Liveness / flags del motor. | +| GET/POST | `/pages` | Listar / crear (`title`, `project_id` acotados). | +| GET/PUT/PATCH/DELETE | `/pages/{id}` | Detail (incl. `project_id`, `updated_at`, `version`); replace raw con OCC; metadata; soft-delete. | +| POST | `/pages/{id}/text`, `/math`, `/voice`, `/replace` | Inserciones de dominio / replace. | +| GET | `/pages/{id}/history`, `/at/{i}`, POST `/restore/{i}` | Time travel. | +| POST/GET | `/pages/{id}/cells...` | Insert, update (**devuelve `version`**), execute, move (**devuelve `version`**), figure. | +| GET/POST | `/diagrams/presets...` | Listar, expand, simulate-source, apply. | +| GET/POST | `/snippets...`, `/latex-symbols...` | Catálogos y favoritos. | +| GET/POST | `/vault...` | Upload, preview, download, borrado diferido, purge. | +| GET/POST | `/pdf/*`, `/pages/{id}/export/*` | Estado del motor, install, compile, export multi-formato. | +| POST | `/export` | Export del sitio estático. | + +
+ +**Contrato OCC (PUT raw / replace):** + +```json +{ + "detail": { + "error_code": "VERSION_CONFLICT", + "message": "La página cambió en otro cliente; recarga e inténtalo de nuevo", + "expected": 5, + "current": 6 + } +} +``` + +El append condicional es atómico bajo el lock por página (`EventStore.append(..., expected_version=)`). + +--- + +## Banco de diagramas + +| `preset_id` | Title | Category | +| :--- | :--- | :--- | +| `voltage_divider` | Divisor de tensión | circuitos | +| `noninverting_opamp` | Op-amp no inversor | circuitos | +| `wheatstone` | Puente de Wheatstone (DC) | circuitos | +| `rc_lowpass` | RC pasa-bajos | circuitos | +| `rc_series_charge` | RC serie — carga | circuitos | +| `half_wave_rectifier` | Rectificador media onda + C | circuitos | +| `rlc_series_step` | RLC serie — escalón | circuitos | +| `second_order_step` | 2º orden — respuesta al escalón | control | +| `pi_controller` | PI + planta 1er orden | control | +| `mass_spring_damper` | Masa-resorte-amortiguador | mecanica | +| `thin_lens` | Lente delgada | optica | +| `qed_moller` | QED e⁻e⁻ (árbol) | particulas | + +Los presets con soporte PySpice degradan a código numpy pedagógico si faltan PySpice / ngspice. +Cabeceras en el LaTeX generado: + +```latex +% lablog-diagram: preset=rc_series_charge version=1 +% lablog-param: C=1e-06 +% lablog-param: R=1000 +% lablog-highlight: R +``` + +--- + +## Atajos de teclado + +`mod` es **⌘** en macOS y **Ctrl** en Windows / Linux. Los chords se editan en +**Preferencias → Atajos** y se exportan con el JSON de preferencias. + +### Globales (configurables) + +| Acción | Chord por defecto | Visualización macOS | Notas | +| :--- | :--- | :--- | :--- | +| Paleta de comandos | `mod+k` | ⌘K | Pages, panels, profiles | +| Guardar (flush del autosave) | `mod+s` | ⌘S | Forces pending PUT | +| Alternar panel de diagramas | `mod+shift+d` | ⌘⇧D | Sidebar tool | +| Alternar panel de parámetros | `mod+shift+p` | ⌘⇧P | Sliders / re-apply | +| Alternar panel de celdas | `mod+shift+c` | ⌘⇧C | Executable cells | +| Alternar modo laboratorio | `mod+shift+l` | ⌘⇧L | Flushes dirty cells on exit | +| Nueva página | `mod+n` | ⌘N | Creates via API | + +Fuente de verdad: [`ui/src/lib/shortcuts.ts`](ui/src/lib/shortcuts.ts) (`DEFAULT_SHORTCUTS`). + +### Editor (integrados) + +| Acción | Shortcut | +| :--- | :---: | +| Buscar / reemplazar | Ctrl+F / Ctrl+H | +| Siguiente / anterior coincidencia | Enter / Shift+Enter | +| Deshacer / rehacer | Ctrl+Z / Ctrl+Y | +| Negrita · Cursiva · Math inline | Ctrl+B · I · E | +| Indentar selección | Tab | +| Envolver selección en delimitadores | type { ( [ $ on a selection | + +El historial del editor sobrevive a inserciones programáticas (symbols, snippets, voice), donde el undo nativo del navegador suele fallar. + +
+Atajos de teclado en preferencias +
+ +--- + +## Experiencia de editor + + +Las preferencias (densidad, fuente, paleta, atajos, perfiles) viven en `localStorage` y se +exportan / importan como JSON desde Preferencias. + +--- + +## Modo laboratorio + +El modo laboratorio es un layout denso orientado a celdas (Python / markdown / LaTeX cells). + +- El source es local hasta blur o guardado explícito; **al salir del lab se hace flush de celdas dirty** + (toolbar, atajos, command palette, settings y unmount) y se resincroniza + `activeVersion` vía `GET /pages/{id}`. +- Los perfiles de teclado incluyen **Laboratorio** (densidad compacta, fuente mono, flag lab). + +--- + +## Compilación PDF real + +La vista previa es **aproximada** (KaTeX + HTML). La salida fiel usa +[Tectonic](https://tectonic-typesetting.github.io/) (self-contained XeTeX). + +- **Compilar PDF** en el encabezado de la preview; la preview se etiqueta **Aproximada**. +- Las celdas Python se renderizan como código + salida + figuras (`fancyvrb`, `\includegraphics`). +- Compilación asíncrona, timeout duro (`504` si se desborda); caché por hash del documento. +- Errores mapeados a la fuente vía marcadores `% lablog-src` (**Celda N · línea M**). +- **Sin `--shell-escape`.** LaTeX no puede ejecutar comandos del SO. +- Binario gestionado: checksum fijado en `LABLOG_DATA_DIR/bin/`; nunca “latest” en runtime. + +> lablog es local y monousuario. No expongas el endpoint de compile públicamente sin +> rate limiting y aislamiento. + +--- + +## Bóveda y adjuntos + +| Propiedad | Comportamiento | +| :--- | :--- | +| Almacenamiento | `LABLOG_DATA_DIR/vault/` + `meta.json` atómico | +| Nombres de archivo | Solo basename (bloquea `../`) | +| Tamaño | Máx. 100 MB → HTTP 413 | +| Ciclo de vida | Borrado programado; force delete con frase de confirmación; purge de expirados | + +--- + +## Formatos de exportación + +| Formato | Cómo | Notas | +| :--- | :--- | :--- | +| `.tex` | Menú Exportar | Serialización completa del documento | +| `.txt` | Menú Exportar | Reducción a texto plano | +| `.pdf` | Tectonic in-app o ruta pandoc | Preferir in-app para celdas/figuras | +| `.docx` | pandoc | Requiere pandoc + TeX en docs con mucha matemática | +| `.ipynb` | Menú Exportar | Notebook Jupyter para celdas + markdown | +| Sitio estático | Export / CI | GitHub Pages | +| HTML Canva | Menú Exportar | HTML orientado a presentación | + +--- + +## Configuración + +Ver [`.env.example`](.env.example). + +| Variable | Default | Propósito | +| :--- | :--- | :--- | +| `LABLOG_DATA_DIR` | `~/.lablog` | Eventos, bóveda, figuras, binarios gestionados | +| `LABLOG_HOST` | `127.0.0.1` | Host de bind de la API | +| `LABLOG_PORT` | `8000` | Puerto de bind de la API (1–65535) | +| `LABLOG_CORS_ORIGINS` | orígenes Vite | Separados por comas | +| `LABLOG_CORS_CREDENTIALS` | `true` | Credenciales CORS | +| `LABLOG_SITE_DIR` | `${data_dir}/site` | Raíz del export estático | + +Nunca commitees secretos. Trata `LABLOG_DATA_DIR` como datos personales de investigación. + +--- + +## Diseño en disco + +```text +$LABLOG_DATA_DIR/ +├── events/ # one JSONL stream per page_id +│ └── .jsonl +├── vault/ # attachments + meta.json +├── figures/ # per-page cell figures +│ └── / +├── bin/ # managed tectonic (optional) +└── site/ # last static export (if configured) +``` + +Los identificadores de página se limitan a un alfabeto seguro (`[A-Za-z0-9_-]{1,128}`) para que las rutas de filesystem no escapen de la raíz de eventos. + +--- + +## Modelo de seguridad + +| Invariante | Mecanismo | +| :--- | :--- | +| page_id no hace traversal | Validación regex en `EventStore` | +| Nombres de upload no escapan la bóveda | Solo basename | +| Tamaño de upload acotado | 100 MB → 413 | +| El código de usuario no cuelga el kernel | Deadline + `interrupt_kernel()` | +| Títulos sin inyección LaTeX | Escape de meta-caracteres al exportar | +| Figuras no salen del root de figures | Resolve + check de contención | +| Meta de bóveda concurrent-safe | Tempfile + rename atómico | +| Eventos corruptos no rompen la página | Se omiten líneas JSONL inválidas | +| Soft-delete rechaza escrituras | 409 al mutar | +| OCC en replace de documento | `expected_version` atómico bajo lock | +| Longitud de title / project_id acotada | Pydantic max_length (500 / 128) | +| Aislamiento de Tectonic | Sin shell-escape | + +Reporta vulnerabilidades en privado vía GitHub Security Advisories o el perfil del +mantenedor; ver [SECURITY.md](SECURITY.md). + +--- + +## Pruebas y calidad + +```bash +# Motor +source .venv/bin/activate +pytest -q +ruff check src tests +mypy -p lablog +bandit -r src/lablog -ll + +# UI +cd ui +npx tsc --noEmit +npm run lint +npm test -- --run +npm run build + +# E2E opcional +npm run test:e2e:install && npm run test:e2e +``` + +| Gate | Umbral / herramienta | +| :--- | :--- | +| Cobertura backend | **≥ 80%** (`pytest-cov`) | +| Typecheck | Mypy strict (paquete), `tsc --noEmit` | +| Lint | Ruff, oxlint / pipeline ESLint | +| Pre-commit | whitespace, ruff, mypy, tsc, oxlint | +| CI | [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — backend + frontend | + +--- + +## Publicar en GitHub Pages + +Las funciones interactivas (editor, celdas, voz) permanecen locales. El exportador estático +produce un sitio renderizado con KaTeX para compartir. + +```bash +source .venv/bin/activate +uv run python - <<'PY' +from lablog.exporter import export_site +print(export_site()) +PY +``` + +1. Repository **Settings → Pages → Source: GitHub Accións** +2. Push to `main` +3. Workflow [`.github/workflows/pages.yml`](.github/workflows/pages.yml) deploys + +Instancia en vivo: [kegouro.github.io/lablog](https://kegouro.github.io/lablog/) + +--- + +## Empaquetado de escritorio + +```bash +./scripts/package_desktop.sh +# → dist/lablog/ (comprimir y distribuir) +``` + +Spec: [`lablog.spec`](lablog.spec). El modelo de voz se excluye por defecto. Trata el bundle +como punto de partida verificado para builds portables, no como instalador universal de un clic. + +--- + +## Hoja de ruta + +| Hito | Estado | +| :--- | :---: | +| Motor event-sourced + proyección | Hecho | +| Voz → intent → LaTeX | Hecho | +| Render estructural | Hecho | +| Celdas ejecutables + timeout | Hecho | +| Bóveda + borrado diferido | Hecho | +| Editor F&R, undo/redo, insert en cursor | Hecho | +| Export estático + Pages | Hecho | +| Escritorio (pywebview) | Hecho | +| Time-travel restore | Hecho | +| PDF in-app + mapeo de errores | Hecho | +| Autocomplete + plantillas CLI | Hecho | +| Presets de diagramas + reaplicar + highlight dual | Hecho (0.3.0) | +| Export Jupyter + PySpice opcional | Hecho (0.3.0) | +| Perfiles UI + atajos | Hecho (0.3.0) | +| OCC endurecido + flush dirty del lab | Hecho (post-0.3.0) | +| BibTeX / citeproc | Planificado | +| Cross-refs de sección / ecuación | Planificado | +| Colaboración P2P / sync multi-dispositivo | Exploratorio | + +--- + +## Cómo citar + +Si lablog apoya trabajo que derive en publicación, cita: + +```bibtex +@software{labarca_lablog, + author = {Labarca Baeza, José}, + title = {{lablog}: a live LaTeX laboratory notebook for working scientists}, + year = {2026}, + version = {0.3.0}, + url = {https://github.com/kegouro/lablog}, + note = {Part of the Pharos Project} +} +``` + +Metadatos legibles por máquina: [`CITATION.cff`](CITATION.cff). + +--- + +## Contribuir + +Ver [CONTRIBUTING.md](CONTRIBUTING.md). Seguridad: [SECURITY.md](SECURITY.md). +Conducta: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Changelog: [CHANGELOG.md](CHANGELOG.md). + +Forma preferida de contribución: PR pequeño, tests del comportamiento que cambias, sin +refactors colaterales. Las reglas de arquitectura en CONTRIBUTING son vinculantes. + +--- + +## Licencia + +Publicado bajo la [licencia MIT](LICENSE). + +--- + +## Agradecimientos + +lablog forma parte del **Proyecto Pharos** — infraestructura científica y educativa que +debe sentirse local, honesta y reconstruible. Identidad y gráficos: José Labarca Baeza. +Idea original concebida con Vicente Muñoz Tolosa. + +
+ +logo lablog + +
+ +USM · Valparaíso · Chile · para escribir la ciencia mientras se hace + +

+ +[![GitHub](https://img.shields.io/badge/github-kegouro%2Flablog-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=48484A)](https://github.com/kegouro/lablog) +[![PyPI](https://img.shields.io/badge/pypi-jose--labarca--lablog-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=A1A1A6)](https://pypi.org/project/jose-labarca-lablog/) +[![Docs](https://img.shields.io/badge/docs-site-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=48484A)](https://kegouro.github.io/lablog/) + +
diff --git a/README.md b/README.md index bd44f3d..d50491d 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,102 @@
-lablog +lablog identity + +# lablog + +**A live LaTeX laboratory notebook for working scientists** + +*Part of the Pharos Project · José Labarca Baeza · Universidad Técnica Federico Santa María · Valparaíso*
-

lablog

+lablog academic hero -

A live LaTeX laboratory notebook for working scientists.

+

- version - CI - Pages - tests - coverage - python - react - license + PyPI version + Python versions + PyPI downloads + CI + Pages + Release + last commit + issues + PRs + stars + forks + languages + code size + coverage + license + Project status

- Live preview + English + Español + Live preview + Install from PyPI + v0.3.0

-Part of the Pharos Project · José Labarca Baeza · USM, Valparaíso + + About + · Gallery + · Features + · Architecture + · Shortcuts + · Install + · Tutorials + · CLI + · Security + · Cite + · Español +
--- +## Table of contents + - - - - - - + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + +
AboutFeaturesArchitectureInstallationQuick startConfiguration1. About2. Gallery3. Features4. Architecture
5. Stack6. Installation7. Quick start8. Tutorials
EditorSecurityTestingPublishingRoadmapLicense9. CLI10. HTTP API11. Diagrams12. Shortcuts
13. Lab mode14. PDF15. Vault16. Export
17. Config18. Data layout19. Security20. Testing
21. Pages22. Roadmap23. Citing24. License
@@ -52,26 +104,115 @@ ## About -> **lablog** is a research-grade notebook that lives where the experiment happens. -> It pairs a structural LaTeX editor with live preview, executable cells, voice dictation -> and an immutable, event-sourced history so that the record of an investigation can be -> reconstructed exactly as it unfolded. +> **lablog** is a research-grade laboratory notebook that lives where the experiment happens. +> It pairs a structural LaTeX editor with live preview, executable cells, parameterised +> diagrams, voice dictation, and an immutable event-sourced history so that the record of +> an investigation can be reconstructed exactly as it unfolded. -Unlike a typesetting tool used after the experiment, `lablog` is meant to run **while the -work is in progress** — gloves on, instrument in front, hands occupied. It is built on -the premise that the act of writing the paper should not be separated from the act of -producing the data. +Unlike a typesetting tool used *after* the experiment, lablog is designed to run **while +the work is in progress** — instrument open, notes incomplete, values still moving. The +guiding premise is simple: the act of writing the paper should not be separated from the +act of producing the data. - - - - - - - - - -
ToolWhen you use it
OverleafAfter the experiment, when the manuscript is being prepared.
TeXstudio / TeXmacsFor traditional LaTeX editing on a desktop.
lablogDuring the experiment: to dictate, execute and preserve as it happens.
+| Tool | When you typically use it | +| :--- | :--- | +| Overleaf | After the experiment, when the manuscript is prepared. | +| TeXstudio / TeXmacs | Traditional desktop LaTeX authoring. | +| Jupyter / JupyterLab | Computational notebooks; prose is secondary. | +| **lablog** | **During** the experiment: dictate, execute, parameterise diagrams, and preserve as it happens. | + +### Design principles + +1. **Local-first.** Default bind address is loopback. Your notes stay under `LABLOG_DATA_DIR`. +2. **Event sourcing, not silent mutation.** Writes append immutable events; state is a pure projection. +3. **Approximate preview + faithful PDF.** KaTeX is for speed; Tectonic is for truth. +4. **Optional weight.** Core install is lean; voice, desktop, and PySpice are extras. +5. **Security as correctness.** Path traversal, shell-escape, size limits, and OCC are invariants, not afterthoughts. + +### Status + +| Item | Value | +| :--- | :--- | +| Distribution | [`jose-labarca-lablog`](https://pypi.org/project/jose-labarca-lablog/) on PyPI | +| Current release | **v0.3.0** ([notes](docs/release-notes-v0.3.0.md)) | +| Licence | MIT | +| Primary language (engine) | Python 3.11+ | +| Primary language (UI) | TypeScript / React 19 | +| Maintainer | José Labarca Baeza | + +--- + +## Gallery + +Real captures from a running instance (Vite + FastAPI, dark theme, v0.3.x). Regeneration script: [`scripts/capture_ui_screenshots.mjs`](scripts/capture_ui_screenshots.mjs). + +
+ +### Workbench + +Main workbench with editor and preview + +Figure 1. Main shell: project groups, structural LaTeX editor, live preview (\section, equation, % lablog-param). + +

+ +### Diagram presets + +Diagrams panel with circuit presets + +Figure 2. Diagram workbench: circuit / control / optics presets with Insert, +Sim, and SPICE badges. + +

+ +### Parameters + +Parameters panel with sliders + +Figure 3. Parameter panel: dial values, dual highlight targets, re-apply diagram / re-apply + sim. + +

+ +### Lab mode + +Laboratory mode with Python cell + +Figure 4. Laboratory mode: dense cell layout, Python source, run / reorder / delete controls. + +

+ +### Preferences and keyboard shortcuts + +Settings dialog showing keyboard shortcuts + +Figure 5. Preferencias: editor font, palettes, accent colour, and editable global chords (mod+…). + +

+ +### Settings overview + +Settings dialog overview + +Figure 6. Full preferences surface (density, motion, laboratory layout, import / export JSON). + +

+ +### Cells panel + +Cells and document with python environment + +Figure 7. Document with embedded \begin{python} cell and parameters open for re-apply. + +

+ +### Brand and architecture art + +lablog graphic kit +  +Architecture illustration + +Figure 8. Identity kit and layered architecture illustration. + +
--- @@ -87,43 +228,43 @@ producing the data. Structural LaTeX renderer - Sections, emphasis, lists, hyperlinks, and inline mathematics flow within paragraphs. KaTeX powers display environments (align, equation, gather, cases, pmatrix) with automatic equation numbering. + Sections, emphasis, lists, hyperlinks, and inline mathematics within prose. KaTeX for display environments (align, equation, gather, cases, pmatrix) with automatic equation numbering. Editor - Line gutter, parameter overlay, auto-save, Ctrl+F find & replace with hit counter, Ctrl+Z / Ctrl+Y undo / redo, Ctrl+B/I/E bold / italic / math, smart delimiter wrapping, snippet and symbol insertion at cursor. + Line gutter, parameter overlay, debounced auto-save with optimistic concurrency (OCC), find & replace, undo / redo that survives programmatic inserts, smart delimiter wrapping, snippets and symbols at the cursor. Voice dictation - Browser SpeechRecognition with an intent detector and local Whisper pipeline. A session safety timeout prevents the recogniser from hanging if the API stalls. + Browser SpeechRecognition with intent parsing, plus optional local Whisper ([voice] extra). Session timeout prevents hung recognisers. Executable cells - Write \begin{python}...\end{python} and run real Jupyter kernels in place. Stdout, results and figures are captured back into the document; the kernel is interrupted on timeout to prevent runaway code. + \begin{python}...\end{python} (and lab-mode cells) run on a real Jupyter kernel. Stdout, results, and figures return into the document; kernels are interrupted on timeout. - Per-page vault - Attach images, CSV, PDF, DOCX, audio and scripts; preview them without leaving the notebook. Metadata is written atomically; uploads are sanitised against path traversal and bounded by an explicit size limit. + Vault + Attach images, CSV, PDF, DOCX, audio, scripts; preview in place. Atomic metadata; basename-only filenames; 100 MB ceiling; time-locked deletion. Event-sourced history - Every edit, execution and attachment is appended as an immutable JSONL event. The current state is a deterministic projection — reproducible, auditable, and the foundation for future time-travel UI. + Every edit, execution, and attachment is append-only JSONL. Time-travel UI scrubs and restores any event index with version-aware OCC on the client. - Project grouping - Pages cluster into projects directly from the sidebar; renaming, deletion and reorganisation are first-class operations. + Diagram workbench + Twelve parameterised presets (circuits, control, mechanics, optics, Feynman). Re-apply without {{placeholders}} via % lablog-param. Dual highlight (editor line + Circuitikz colour). Optional PySpice cells with numpy fallback. - Export - LaTeX (.tex), plain text, HTML, PDF and DOCX via pandoc; Jupyter notebook (.ipynb); static site for GitHub Pages; Canva-ready HTML. Titles are escaped to prevent LaTeX injection. + Personalisation + Density, editor font, Nord palette, reduced motion, profiles (Laboratory / Paper / Teaching), exportable preferences JSON, configurable keyboard shortcuts. - Diagram workbench - Twelve parameterized presets (circuits, control, mechanics, optics, Feynman). Dial values, re-apply TikZ, optional PySpice cells with numpy fallback, dual highlight (editor line + colored circuitikz nodes). + Export + .tex, plain text, HTML, PDF, DOCX (pandoc), Jupyter .ipynb, static site for GitHub Pages, Canva-ready HTML. Titles LaTeX-escaped. - Personalization - Density, editor font, Nord palette, reduced motion, lab/paper/teaching profiles, exportable preferences JSON, configurable keyboard shortcuts. + Desktop + Native window via pywebview ([desktop]); portable PyInstaller bundle for offline distribution. @@ -132,57 +273,108 @@ producing the data. ## Architecture +
+Architecture layers +
+ ```mermaid %%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6', 'secondaryColor':'#48484A', 'tertiaryColor':'#1C1C1E' }}}%% -flowchart LR - subgraph UI[lablog UI] - direction TB - React["React 19 + Vite"] - Tailwind["Tailwind CSS v4"] - Zustand["Zustand store"] - KaTeX["KaTeX renderer"] +flowchart TB + subgraph Client["Client (browser or pywebview)"] + UI["React 19 · Vite 8 · Zustand"] + ED["LaTeX editor · OCC autosave"] + PV["KaTeX preview · PDF panel"] + LB["Lab canvas · cells"] + DG["Diagrams · parameters"] + end + + subgraph Engine["Engine (FastAPI · localhost)"] + API["REST /api/v1"] + CMD["commands.py"] + PRJ["projector · projections"] + AST["latex_ast · serialize"] + KER["code_engine · Jupyter"] + PDF["pdf_engine · Tectonic"] + VLT["vault"] + DIA["diagrams · expand · pyspice"] end - subgraph Engine[lablog Engine] - direction TB - FastAPI["FastAPI"] - Events["Event Store (JSONL)"] - Projector["Projector"] - AST["LaTeX AST"] - Jupyter["Jupyter / Python"] - Vault["Vault"] + subgraph Disk["LABLOG_DATA_DIR"] + EV["events/*.jsonl"] + FIG["figures/"] + VAULT["vault/"] + BIN["bin/tectonic"] + SITE["site/ export"] end - User((Researcher)) - User --> UI - UI <-->|HTTP| Engine - Engine -->|export| Site["Static site"] - Site -->|GitHub Actions| Pages["GitHub Pages"] + UI --> API + ED --> API + LB --> API + DG --> API + API --> CMD + CMD --> EV + PRJ --> EV + KER --> FIG + VLT --> VAULT + PDF --> BIN + API --> SITE ``` -The engine is decoupled from the interface: it can be driven from the CLI, embedded in -tests, or replaced by another front-end. The projector reconstructs the current -`DocumentNode` from the event log; the renderer reduces that tree to HTML. +### Event sourcing + +```mermaid +%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6' }}}%% +sequenceDiagram + participant UI as UI / CLI + participant API as FastAPI + participant ES as EventStore + participant P as Projector + + UI->>API: PUT /pages/{id} raw + version + API->>ES: append(document_replaced, expected_version) + alt version mismatch + ES-->>API: VersionConflictError + API-->>UI: 409 VERSION_CONFLICT + current + else ok + ES-->>API: fsync JSONL line + API->>P: project(events) + P-->>API: PageDetail + API-->>UI: latex, ast, version, project_id + end +``` + +**Rules enforced in code and CONTRIBUTING:** + +1. Never mutate the projected AST from the API; append an event and re-project. +2. Paths only through `src/lablog/config.py`. +3. Wire shapes mirrored in `ui/src/lib/api.ts`. +4. Network I/O kept out of leaf components when possible (Zustand + hooks). +5. Diagrams: presets in `catalog.py`, clamp/expand in `expand.py`, SPICE optional in `pyspice_sim.py`.
-Module map +Module map (source tree)
| Path | Responsibility | | :--- | :--- | -| [`src/lablog/api.py`](src/lablog/api.py) | FastAPI routes; export pipeline; vault uploads. | -| [`src/lablog/event_store.py`](src/lablog/event_store.py) | Append-only JSONL log; path-validated page identifiers. | -| [`src/lablog/events.py`](src/lablog/events.py) | Event schema and constructors. | -| [`src/lablog/projector.py`](src/lablog/projector.py) | Pure folding of events into a `DocumentNode`. | -| [`src/lablog/latex_ast.py`](src/lablog/latex_ast.py) | Tokeniser; environment whitelist; round-trip serialiser. | -| [`src/lablog/code_engine.py`](src/lablog/code_engine.py) | Jupyter kernel manager with enforced timeout. | -| [`src/lablog/vault.py`](src/lablog/vault.py) | Attachment store with atomic metadata and time-locked deletion. | -| [`src/lablog/exporter.py`](src/lablog/exporter.py) | Static site exporter for GitHub Pages. | -| [`ui/src/lib/latex-render.ts`](ui/src/lib/latex-render.ts) | Structural renderer (prose × mathematics). | -| [`ui/src/components/editor/latex-editor.tsx`](ui/src/components/editor/latex-editor.tsx) | Editor, find & replace, undo / redo, shortcuts. | -| [`ui/src/components/preview/latex-preview.tsx`](ui/src/components/preview/latex-preview.tsx) | Node coalescing & live preview. | -| [`ui/src/stores/app-store.ts`](ui/src/stores/app-store.ts) | Single source of truth on the client; persistent state. | +| [`src/lablog/api.py`](src/lablog/api.py) | HTTP surface, OCC on replace, vault, diagrams, export. | +| [`src/lablog/event_store.py`](src/lablog/event_store.py) | Append-only JSONL; per-page lock; conditional append (`expected_version`). | +| [`src/lablog/events.py`](src/lablog/events.py) | Event types and constructors. | +| [`src/lablog/projector.py`](src/lablog/projector.py) | Pure fold of events into page state. | +| [`src/lablog/projections.py`](src/lablog/projections.py) | Read models: detail, summary, history, cells. | +| [`src/lablog/commands.py`](src/lablog/commands.py) | Domain commands (create, replace, cells, restore). | +| [`src/lablog/latex_ast.py`](src/lablog/latex_ast.py) | Parse / serialise document tree. | +| [`src/lablog/code_engine.py`](src/lablog/code_engine.py) | Jupyter kernel with timeout interrupt. | +| [`src/lablog/vault.py`](src/lablog/vault.py) | Attachments, atomic meta, deletion schedule. | +| [`src/lablog/exporter.py`](src/lablog/exporter.py) | Static site and notebook export. | +| [`src/lablog/pdf_engine.py`](src/lablog/pdf_engine.py) | Tectonic install, warm, compile, error mapping. | +| [`src/lablog/diagrams/`](src/lablog/diagrams/) | Presets, expand, highlight, PySpice / numpy. | +| [`src/lablog/cli.py`](src/lablog/cli.py) | `lablog` entry point. | +| [`ui/src/stores/app-store.ts`](ui/src/stores/app-store.ts) | Client state, preferences, flush hooks. | +| [`ui/src/hooks/use-page-update.ts`](ui/src/hooks/use-page-update.ts) | Debounced autosave, serialised PUT, 409 retry. | +| [`ui/src/components/editor/latex-editor.tsx`](ui/src/components/editor/latex-editor.tsx) | Editor surface. | +| [`ui/src/components/lab/lab-canvas.tsx`](ui/src/components/lab/lab-canvas.tsx) | Lab mode; dirty-cell flush on exit. |
@@ -190,21 +382,13 @@ tests, or replaced by another front-end. The projector reconstructs the current ## Stack - - - - - - - - - - - - - - -
LayerStack
EnginePython 3.11+, FastAPI, Pydantic, Jupyter Client, faster-whisper.
PersistenceJSONL event log; atomic metadata writes; deterministic projection.
InterfaceReact 19, TypeScript, Vite 8, Tailwind CSS v4, Zustand, shadcn/ui, Radix.
MathematicsKaTeX with automatic numbering for display environments.
Toolinguv (Python), npm (Node), Ruff, Mypy (strict), oxlint, pytest, GitHub Actions.
+| Layer | Technologies | +| :--- | :--- | +| Engine | Python 3.11+, FastAPI, Pydantic v2, Jupyter Client, optional faster-whisper / PySpice | +| Persistence | JSONL event log, atomic renames for vault meta, deterministic projection | +| Interface | React 19, TypeScript, Vite 8, Tailwind CSS v4, Zustand, shadcn/ui, Radix | +| Mathematics | KaTeX (preview); Tectonic / XeTeX (PDF) | +| Tooling | uv, npm, Ruff, Mypy (strict), oxlint, pytest (≥80% cov), Vitest, Playwright (smoke), pre-commit, GitHub Actions | --- @@ -213,22 +397,24 @@ tests, or replaced by another front-end. The projector reconstructs the current ### From PyPI (recommended) ```bash -pip install jose-labarca-lablog +pip install -U jose-labarca-lablog lablog serve ``` -The wheel bundles the compiled UI, so a simple `pip install` is enough to run the -application. The optional `desktop` extra adds the native window: +Open the UI served by the engine (bundled wheel includes compiled `ui/dist`), or point a +dev front-end at the API. -```bash -pip install jose-labarca-lablog[desktop] -lablog app -``` +| Extra | Install | Purpose | +| :--- | :--- | :--- | +| Desktop | `pip install "jose-labarca-lablog[desktop]"` | Native window (`lablog app`) | +| Offline voice | `pip install "jose-labarca-lablog[voice]"` | Local Whisper (large download) | +| SPICE | `pip install "jose-labarca-lablog[pyspice]"` | PySpice cells (needs `ngspice` on PATH) | +| Dev | `pip install "jose-labarca-lablog[dev]"` | pytest, ruff, mypy, bandit, pre-commit | ### From source -> **Prerequisites.** Python 3.11 or newer, Node 22, [`uv`](https://docs.astral.sh/uv/), -> and `npm`. Optional: `pandoc` and `xelatex` (or `pdflatex`) for PDF and DOCX export. +> **Prerequisites.** Python ≥ 3.11, Node 22, [uv](https://docs.astral.sh/uv/), `npm`. +> Optional: `pandoc` (+ TeX) for DOCX/PDF via pandoc; Tectonic is managed by lablog for in-app PDF. ```bash git clone https://github.com/kegouro/lablog.git @@ -240,253 +426,487 @@ cp .env.example .env cd ui && npm install && cd .. ``` -Optional extras (the core install stays lean): - -| Extra | Command | Adds | -| :--- | :--- | :--- | -| Desktop app | `uv sync --extra desktop` | `pywebview` — native window via `lablog app`. | -| Offline voice | `uv sync --extra voice` | local Whisper model + audio capture (several hundred MB). | +```bash +# Optional extras +uv sync --extra desktop +uv sync --extra voice +uv sync --extra pyspice +``` --- ## Quick start -### Run the engine +### Development (two processes) ```bash +# Terminal A — API source .venv/bin/activate uvicorn lablog.api:app --host 127.0.0.1 --port 8000 --reload -``` - -### Run the interface -```bash -cd ui -npm run dev +# Terminal B — UI +cd ui && npm run dev ``` -| Endpoint | URL | +| Surface | URL | | :--- | :--- | -| User interface | | -| API | | -| Liveness probe | | +| UI (Vite) | http://127.0.0.1:5173 | +| API | http://127.0.0.1:8000/api/v1 | +| Health | http://127.0.0.1:8000/api/v1/health | +| OpenAPI | http://127.0.0.1:8000/docs | -For a production build served by the engine itself: +### Single process (production-like) ```bash cd ui && npm run build && cd .. uvicorn lablog.api:app --host 127.0.0.1 --port 8000 +# or +lablog serve --host 127.0.0.1 --port 8000 +``` + +### Desktop + +```bash +uv sync --extra desktop +cd ui && npm run build && cd .. +lablog app ``` -The interface is then served statically from `ui/dist` by FastAPI. +### One-liner smoke (API only) + +```bash +curl -s http://127.0.0.1:8000/api/v1/health | python -m json.tool +``` --- -## Desktop application +## Tutorials -`lablog` runs as a **native desktop window** — no browser, no internet. The engine -binds to `127.0.0.1` on an ephemeral port inside the process and the interface opens in -the operating system's own webview (WKWebView on macOS, WebView2 on Windows, WebKitGTK on -Linux). Every asset — JavaScript, CSS, fonts and the KaTeX renderer — is -bundled, so the application is **fully offline**. +### Tutorial 1 — First page from the CLI ```bash -uv sync --extra desktop # installs pywebview -cd ui && npm run build && cd .. -lablog app # opens the native window +source .venv/bin/activate + +# Create an empty page +lablog create-page --title "RC lab session" + +# Or seed from a physics template +lablog new --title "Optics notes" --template article_physics + +lablog list-pages +lablog render # print projected LaTeX +lablog events # inspect JSONL history +``` + +### Tutorial 2 — Parameterised RC circuit in the UI + +1. Start API + UI (Quick start). +2. Create a page from the sidebar. +3. Open **Diagrams** → **RC serie — carga**. +4. Insert diagram (or insert + simulation cell). +5. Open **Parameters**: adjust `R`, `C`, `V0`. +6. Re-apply; confirm `% lablog-param` comments and TikZ update. +7. Focus a parameter: dual highlight (gutter line + Circuitikz `color=`). +8. Export **Notebook Jupyter (.ipynb)** or **Compilar PDF**. + +```bash +# Equivalent expand from CLI +lablog diagrams list +lablog diagrams expand rc_series_charge --json +``` + +### Tutorial 3 — Executable cell and figure + +In the editor (or Lab mode): + +```latex +\begin{python}[label=demo] +import numpy as np +import matplotlib.pyplot as plt +t = np.linspace(0, 5, 200) +plt.plot(t, np.exp(-t)) +plt.xlabel("t") +plt.ylabel("e^{-t}") +\end{python} ``` -> **Offline note.** The core notebook (editor, live preview, executable cells, vault) is -> entirely offline. The in-browser dictation button relies on the platform speech API and -> degrades gracefully when offline; for offline dictation install the local Whisper model -> with `uv sync --extra voice`. +Run the cell from the **Cells** panel or Lab canvas. Output and figures are stored under +`LABLOG_DATA_DIR/figures//` and projected into the AST. -### Packaging a portable bundle +### Tutorial 4 — Time travel and OCC -To ship a self-contained folder that runs without any Python install: +1. Edit the page several times (autosave every ~300 ms of idle). +2. Open history / time-travel; scrub the event index; restore a past version. +3. Concurrent edits: client sends `version`; on conflict the API returns `409` with + `error_code: VERSION_CONFLICT` and `current`. The UI retries once or requeues the draft. + +### Tutorial 5 — Static site for GitHub Pages ```bash -./scripts/package_desktop.sh # builds the UI, then PyInstaller -# → dist/lablog/ (zip it and run dist/lablog/lablog) +source .venv/bin/activate +# Prefer versioning data inside the repo for public notes: +# LABLOG_DATA_DIR=./data + +python - <<'PY' +from lablog.exporter import export_site +print(export_site()) +PY ``` -The bundle is described by [`lablog.spec`](lablog.spec). It includes `ui/dist` and the -Jupyter kernel used for cell execution, and excludes the heavy voice model by default. -The kernel and `pyzmq` are discovered dynamically, so on a fresh platform the spec's -`collect_all` lists may need a small adjustment — treat it as a verified starting -point rather than a one-click cross-platform installer. +Enable **Settings → Pages → GitHub Actions** on a fork; [`.github/workflows/pages.yml`](.github/workflows/pages.yml) +publishes on push to `main`. --- -## Real PDF compilation +## CLI reference -The live preview is fast but **approximate** — it renders the common subset of -LaTeX with KaTeX and a focused HTML renderer. For a faithful document, lablog compiles -the page to a **real PDF** with [Tectonic](https://tectonic-typesetting.github.io/) -(a self-contained XeTeX engine). The first compile downloads and caches the required -TeX packages once; **every subsequent compile is fully offline**. - -- The **Compilar PDF** button lives in the preview header; the preview itself is - labelled **"Aproximada"** so the distinction is explicit. -- Executable `\begin{python}` cells are rendered into the PDF as **code + output + - figure** (code and output via `fancyvrb`, figures via `\includegraphics`). -- Compilation runs asynchronously with a hard timeout; runaway documents return `504` - instead of hanging the engine. Output is cached by document hash, so recompiling an - unchanged page is instant. -- When compilation fails, the error panel maps TeX errors back to the source via - injected `% lablog-src` markers, showing **"Celda N · línea M: message"** rather than - a meaningless line in the generated `.tex`. - -> **Security.** Tectonic runs **without `--shell-escape`** — LaTeX cannot execute -> operating-system commands. lablog is local and single-user; the compile endpoint -> assumes you are compiling your own content and must not be exposed publicly without -> adding rate limiting and isolation. - -> **Engine note.** If `tectonic` is already on your `PATH` it is used directly. -> Otherwise the preview shows an **install banner** that downloads the pinned, -> checksum-verified binary once to `LABLOG_DATA_DIR/bin/` and warms the common -> packages (offline afterwards). When a managed binary falls behind the version -> this app pins, the banner offers a verified re-install — it never fetches -> "latest" at runtime, preserving the checksum trust chain. +```text +lablog {create-page,new,list-pages,append-text,render,events,serve,app,diagrams} +``` + +| Command | Purpose | +| :--- | :--- | +| `create-page` | Create a page (title / project). | +| `new` | Create page; optional `--template`. | +| `list-pages` | List page summaries. | +| `append-text` | Append text event. | +| `render` | Project and print LaTeX. | +| `events` | Dump event log for a page. | +| `serve` | Run uvicorn-backed API (serves UI if built). | +| `app` | Desktop shell (`[desktop]`). | +| `diagrams list` | Catalogue presets. | +| `diagrams expand ` | Expand TikZ (+ params) to stdout / JSON. | + +Examples: + +```bash +lablog serve --host 127.0.0.1 --port 8000 +lablog diagrams list +lablog diagrams expand thin_lens --set f=0.1 --set do=0.3 +``` --- -## Configuration +## HTTP API surface -All configurable paths and addresses are surfaced through environment variables. -See [`.env.example`](.env.example) for the full list. +Base path: **`/api/v1`**. Interactive schema: `/docs` (Swagger) when the server is running. -| Variable | Default | Purpose | +
+Core resources (summary) + +
+ +| Method | Path | Notes | | :--- | :--- | :--- | -| `LABLOG_DATA_DIR` | `~/.lablog` | Root for events, vault, figures and exported site. | -| `LABLOG_HOST` | `127.0.0.1` | Bind address for the API. | -| `LABLOG_PORT` | `8000` | Port for the API. | -| `LABLOG_CORS_ORIGINS` | `http://localhost:5173, http://127.0.0.1:5173` | Comma-separated CORS origins. | -| `LABLOG_SITE_DIR` | `${data_dir}/site` | Destination of the static site exporter. | +| GET | `/health` | Liveness / engine flags. | +| GET/POST | `/pages` | List / create (`title`, `project_id` bounded). | +| GET/PUT/PATCH/DELETE | `/pages/{id}` | Detail (incl. `project_id`, `updated_at`, `version`); raw replace with OCC; metadata; soft-delete. | +| POST | `/pages/{id}/text`, `/math`, `/voice`, `/replace` | Domain inserts / replace. | +| GET | `/pages/{id}/history`, `/at/{i}`, POST `/restore/{i}` | Time travel. | +| POST/GET | `/pages/{id}/cells...` | Insert, update (**returns `version`**), execute, move (**returns `version`**), figure. | +| GET/POST | `/diagrams/presets...` | List, expand, simulate-source, apply. | +| GET/POST | `/snippets...`, `/latex-symbols...` | Catalogues and favourites. | +| GET/POST | `/vault...` | Upload, preview, download, delayed delete, purge. | +| GET/POST | `/pdf/*`, `/pages/{id}/export/*` | Engine status, install, compile, multi-format export. | +| POST | `/export` | Static site export. | + +
+ +**OCC contract (PUT raw / replace):** -> **Note.** `lablog` never reads secrets from the data directory and never embeds them -> in exports. Treat `LABLOG_DATA_DIR` as personal notes; commit only what you intend to -> publish. +```json +{ + "detail": { + "error_code": "VERSION_CONFLICT", + "message": "La página cambió en otro cliente; recarga e inténtalo de nuevo", + "expected": 5, + "current": 6 + } +} +``` + +Conditional append is atomic under the per-page file lock (`EventStore.append(..., expected_version=)`). --- -## Editor experience +## Diagram workbench + +| `preset_id` | Title | Category | +| :--- | :--- | :--- | +| `voltage_divider` | Divisor de tensión | circuitos | +| `noninverting_opamp` | Op-amp no inversor | circuitos | +| `wheatstone` | Puente de Wheatstone (DC) | circuitos | +| `rc_lowpass` | RC pasa-bajos | circuitos | +| `rc_series_charge` | RC serie — carga | circuitos | +| `half_wave_rectifier` | Rectificador media onda + C | circuitos | +| `rlc_series_step` | RLC serie — escalón | circuitos | +| `second_order_step` | 2º orden — respuesta al escalón | control | +| `pi_controller` | PI + planta 1er orden | control | +| `mass_spring_damper` | Masa-resorte-amortiguador | mecanica | +| `thin_lens` | Lente delgada | optica | +| `qed_moller` | QED e⁻e⁻ (árbol) | particulas | + +Presets with PySpice support degrade to pedagogical numpy code when PySpice / ngspice +are absent. Headers in generated LaTeX: + +```latex +% lablog-diagram: preset=rc_series_charge version=1 +% lablog-param: C=1e-06 +% lablog-param: R=1000 +% lablog-highlight: R +``` + +--- + +## Keyboard shortcuts + +`mod` is **⌘** on macOS and **Ctrl** on Windows / Linux. Chords are editable under +**Preferencias → Atajos** and exported with preferences JSON. + +### Global (configurable) + +| Action | Default chord | macOS display | Notes | +| :--- | :--- | :--- | :--- | +| Command palette | `mod+k` | ⌘K | Pages, panels, profiles | +| Save (flush autosave) | `mod+s` | ⌘S | Forces pending PUT | +| Toggle diagrams panel | `mod+shift+d` | ⌘⇧D | Sidebar tool | +| Toggle parameters panel | `mod+shift+p` | ⌘⇧P | Sliders / re-apply | +| Toggle cells panel | `mod+shift+c` | ⌘⇧C | Executable cells | +| Toggle laboratory mode | `mod+shift+l` | ⌘⇧L | Flushes dirty cells on exit | +| New page | `mod+n` | ⌘N | Creates via API | + +Source of truth: [`ui/src/lib/shortcuts.ts`](ui/src/lib/shortcuts.ts) (`DEFAULT_SHORTCUTS`). + +### Editor (built-in) | Action | Shortcut | | :--- | :---: | -| Find & replace (in-editor) | Ctrl+F · Ctrl+H | -| Next / previous match | Enter · Shift+Enter | -| Undo / redo | Ctrl+Z · Ctrl+Y | -| Bold · Italic · Inline math | Ctrl+B · Ctrl+I · Ctrl+E | +| Find / replace | Ctrl+F / Ctrl+H | +| Next / previous match | Enter / Shift+Enter | +| Undo / redo | Ctrl+Z / Ctrl+Y | +| Bold · Italic · Inline math | Ctrl+B · I · E | | Indent selection | Tab | -| Command palette | Ctrl+K | | Wrap selection in delimiters | type { ( [ $ on a selection | -The history stack survives programmatic insertions (symbols, snippets, voice dictation), -which is where browser-native `undo` typically breaks. +Editor history survives programmatic insertions (symbols, snippets, voice), where +browser-native undo usually breaks. + +
+Keyboard shortcuts in preferences +
--- -## Security model +## Editor experience + + +Preferences (density, font, palette, shortcuts, profiles) live in `localStorage` and can +be exported / imported as JSON from Settings. -Security is treated as a correctness property. The following invariants are enforced -in the engine and exercised by the test suite where applicable: +--- + +## Laboratory mode + +Lab mode is a dense layout for cell-first work (Python / markdown / LaTeX cells). + +- Source is local until blur or explicit save; **leaving lab mode flushes dirty cells** + (toolbar, shortcuts, command palette, settings, and unmount) and resynchronises + `activeVersion` via `GET /pages/{id}`. +- Keyboard profiles include a **Laboratory** preset (compact density, mono font, lab flag). + +--- + +## Real PDF compilation + +Live preview is **approximate** (KaTeX + HTML). Faithful output uses +[Tectonic](https://tectonic-typesetting.github.io/) (self-contained XeTeX). + +- **Compilar PDF** in the preview header; preview labelled **Aproximada**. +- Python cells render as code + output + figures (`fancyvrb`, `\includegraphics`). +- Async compile, hard timeout (`504` on runaway); cache by document hash. +- Errors map to source via `% lablog-src` markers (**Celda N · línea M**). +- **No `--shell-escape`.** LaTeX cannot run OS commands. +- Managed binary: checksum-pinned under `LABLOG_DATA_DIR/bin/`; never “latest” at runtime. + +> lablog is local and single-user. Do not expose the compile endpoint publicly without +> rate limiting and isolation. + +--- + +## Vault & attachments + +| Property | Behaviour | +| :--- | :--- | +| Storage | `LABLOG_DATA_DIR/vault/` + atomic `meta.json` | +| Filenames | Basename only (blocks `../`) | +| Size | Max 100 MB → HTTP 413 | +| Lifecycle | Soft delete schedule; force delete with confirmation phrase; purge expired | + +--- + +## Export formats + +| Format | How | Notes | +| :--- | :--- | :--- | +| `.tex` | Export menu | Full document serialisation | +| `.txt` | Export menu | Plain reduction | +| `.pdf` | In-app Tectonic or pandoc path | Prefer in-app for cells/figures | +| `.docx` | pandoc | Requires pandoc + TeX for math-heavy docs | +| `.ipynb` | Export menu | Jupyter notebook for cells + markdown | +| Static site | Export / CI | GitHub Pages | +| Canva HTML | Export menu | Presentation-oriented HTML | + +--- + +## Configuration + +See [`.env.example`](.env.example). + +| Variable | Default | Purpose | +| :--- | :--- | :--- | +| `LABLOG_DATA_DIR` | `~/.lablog` | Events, vault, figures, managed binaries | +| `LABLOG_HOST` | `127.0.0.1` | API bind host (invalid port → 8000) | +| `LABLOG_PORT` | `8000` | API bind port (1–65535) | +| `LABLOG_CORS_ORIGINS` | Vite dev origins | Comma-separated | +| `LABLOG_CORS_CREDENTIALS` | `true` | CORS credentials | +| `LABLOG_SITE_DIR` | `${data_dir}/site` | Static export root | + +Never commit secrets. Treat `LABLOG_DATA_DIR` as personal research data. + +--- + +## On-disk layout + +```text +$LABLOG_DATA_DIR/ +├── events/ # one JSONL stream per page_id +│ └── .jsonl +├── vault/ # attachments + meta.json +├── figures/ # per-page cell figures +│ └── / +├── bin/ # managed tectonic (optional) +└── site/ # last static export (if configured) +``` + +Page identifiers are constrained to a safe alphabet (`[A-Za-z0-9_-]{1,128}`) so +filesystem paths cannot escape the events root. + +--- + +## Security model | Invariant | Mechanism | | :--- | :--- | -| Page identifiers cannot escape the events directory. | Regex-validated identifiers (UUID-shaped) reject path traversal at the `EventStore` boundary. | -| Uploaded filenames cannot escape the vault. | The filename is reduced to its basename before reaching the filesystem. | -| Uploads are bounded. | Hard ceiling of 100 MB; oversized requests return `413`. | -| User code cannot block the kernel indefinitely. | A monotonic deadline triggers `interrupt_kernel()`; the error is reported back to the cell. | -| The document title cannot inject LaTeX during export. | All ten LaTeX-meta characters are escaped before reaching `\title{...}`. | -| Cell figures cannot be served from outside the figure root. | Paths are resolved against `figures_dir` and rejected if they escape. | -| Vault metadata cannot be corrupted by concurrent writes. | `meta.json` is written via tempfile and atomic `rename`. | -| Corrupted events do not brick a page. | Invalid JSONL lines are skipped at read time and the rest of the log is recovered. | +| Page IDs cannot traverse directories | Regex validation at `EventStore` | +| Upload names cannot escape vault | Basename only | +| Upload size bounded | 100 MB → 413 | +| User code cannot hang the kernel | Deadline + `interrupt_kernel()` | +| Titles cannot inject LaTeX | Meta-character escape on export | +| Figures cannot leave figure root | Resolve + containment check | +| Vault meta concurrent-safe | Tempfile + atomic rename | +| Corrupt events do not brick a page | Skip bad JSONL lines | +| Soft-deleted pages reject writes | 409 on mutate | +| OCC on document replace | Atomic `expected_version` under lock | +| Title / project_id length bounded | Pydantic max_length (500 / 128) | +| Tectonic isolation | No shell-escape | + +Report vulnerabilities privately via GitHub Security Advisories or the maintainer +profile; see [SECURITY.md](SECURITY.md). --- -## Testing & quality +## Testing & quality ```bash -# Backend +# Engine +source .venv/bin/activate pytest -q -ruff check . -mypy . -bandit -r src/lablog tests -ll -x .venv,prototypes,dist,ui +ruff check src tests +mypy -p lablog +bandit -r src/lablog -ll -# Frontend +# UI cd ui npx tsc --noEmit -npm run build npm run lint npm test -- --run +npm run build + +# Optional e2e +npm run test:e2e:install && npm run test:e2e ``` -The project ships with **130+ backend tests** covering the parser, projector, event store, -vault, code engine, snippets, symbols, and the public API, with a coverage threshold of -**80%**. The frontend is type-checked under strict TypeScript, linted with oxlint, tested -with Vitest and built with Vite. +| Gate | Threshold / tool | +| :--- | :--- | +| Backend coverage | **≥ 80%** (`pytest-cov`) | +| Typecheck | Mypy strict (package), `tsc --noEmit` | +| Lint | Ruff, oxlint / ESLint pipeline | +| Pre-commit | whitespace, ruff, mypy, tsc, oxlint | +| CI | [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — backend + frontend | --- ## Publishing to GitHub Pages -`lablog` exports a static, KaTeX-rendered version of the notebook for public sharing. -The interactive surface (editor, executable cells, voice) remains local. +Interactive features (editor, cells, voice) remain local. The static exporter produces a +KaTeX-rendered site for sharing. ```bash source .venv/bin/activate uv run python - <<'PY' from lablog.exporter import export_site -export_site() +print(export_site()) PY ``` -A workflow at [`.github/workflows/pages.yml`](.github/workflows/pages.yml) reproduces the -same export in CI and publishes it on every push to `main`. To enable it on a fork: +1. Repository **Settings → Pages → Source: GitHub Actions** +2. Push to `main` +3. Workflow [`.github/workflows/pages.yml`](.github/workflows/pages.yml) deploys + +Live instance: [kegouro.github.io/lablog](https://kegouro.github.io/lablog/) + +--- + +## Desktop packaging + +```bash +./scripts/package_desktop.sh +# → dist/lablog/ (zip and distribute) +``` -1. In **Settings → Pages**, set the source to **GitHub Actions**. -2. Push to `main`. -3. The deployment URL appears in the workflow summary. +Spec: [`lablog.spec`](lablog.spec). Voice model excluded by default. Treat the bundle as +a verified starting point for portable builds, not a universal one-click installer. --- ## Roadmap - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MilestoneStatus
Event-sourced engine with deterministic projectionDone
Voice → intent → LaTeX pipelineDone
Structural LaTeX renderer (sections, lists, environments)Done
Executable cells with kernel timeoutDone
Vault with previews and time-locked deletionDone
Editor: find & replace, undo / redo, cursor-aware insertionDone
Static export & GitHub Pages deploymentDone
Native desktop app (offline, pywebview)Done
Time-travel: scrub and restore any point of a page's historyDone
Full LaTeX mode: raw compilation, templates menu, error-to-line jumpDone
Version diff inside the time-travel panelDone
Portable PyInstaller bundleBeta
In-app PDF compilation with line-aware error reportingDone
LaTeX autocomplete + physics templates + lablog new --templateDone
Multi-file includes (\input{page:…}) at compile timeDone (minimal)
Diagram presets + Jupyter / optional PySpiceDone (0.3.0)
Re-apply params, dual highlight, .ipynb exportDone (0.3.0)
UI profiles + configurable shortcutsDone (0.3.0)
BibTeX / full citeprocPlanned
Section and equation cross-referencesPlanned
P2P collaboration and device syncExploratory
+| Milestone | Status | +| :--- | :---: | +| Event-sourced engine + projection | Done | +| Voice → intent → LaTeX | Done | +| Structural renderer | Done | +| Executable cells + timeout | Done | +| Vault + delayed delete | Done | +| Editor F&R, undo/redo, cursor insert | Done | +| Static export + Pages | Done | +| Desktop (pywebview) | Done | +| Time-travel restore | Done | +| In-app PDF + error mapping | Done | +| Autocomplete + templates CLI | Done | +| Diagram presets + re-apply + dual highlight | Done (0.3.0) | +| Jupyter export + optional PySpice | Done (0.3.0) | +| UI profiles + shortcuts | Done (0.3.0) | +| OCC harden + lab dirty flush | Done (post-0.3.0) | +| BibTeX / citeproc | Planned | +| Section / equation cross-refs | Planned | +| P2P collab / multi-device sync | Exploratory | --- ## Citing -If `lablog` supports work that leads to publication, the recommended citation is: +If lablog supports work that leads to a publication, please cite: ```bibtex @software{labarca_lablog, @@ -499,31 +919,44 @@ If `lablog` supports work that leads to publication, the recommended citation is } ``` -Also see [`CITATION.cff`](CITATION.cff). +Machine-readable metadata: [`CITATION.cff`](CITATION.cff). --- ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md). -Community standards: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). +See [CONTRIBUTING.md](CONTRIBUTING.md). Security: [SECURITY.md](SECURITY.md). +Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Changelog: [CHANGELOG.md](CHANGELOG.md). + +Preferred contribution shape: small PR, tests for the behaviour you change, no drive-by +refactors. Architecture rules in CONTRIBUTING are binding. --- ## License -`lablog` is released under the [MIT License](LICENSE). +Released under the [MIT License](LICENSE). --- ## Acknowledgements -`lablog` is part of the **Pharos Project** — an effort to lower the barrier of entry -to scientific and educational infrastructure. Identity, logo and banner by -José Labarca Baeza. Original idea conceived with Vicente Muñoz Tolosa. +lablog is part of the **Pharos Project** — infrastructure for scientific and educational +work that should feel local, honest, and reconstructible. Identity and graphics by +José Labarca Baeza. Original idea conceived with Vicente Muñoz Tolosa.
-USM · Valparaíso · Chile · built to let the science flow +lablog logo + +
+ +USM · Valparaíso · Chile · built so the science can be written as it is done + +

+ +[![GitHub](https://img.shields.io/badge/github-kegouro%2Flablog-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=48484A)](https://github.com/kegouro/lablog) +[![PyPI](https://img.shields.io/badge/pypi-jose--labarca--lablog-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=A1A1A6)](https://pypi.org/project/jose-labarca-lablog/) +[![Docs](https://img.shields.io/badge/docs-site-1C1C1E?style=for-the-badge&labelColor=1C1C1E&color=48484A)](https://kegouro.github.io/lablog/)
diff --git a/docs/assets/architecture-layers.jpg b/docs/assets/architecture-layers.jpg new file mode 100644 index 0000000..578b5d7 Binary files /dev/null and b/docs/assets/architecture-layers.jpg differ diff --git a/docs/assets/hero-academic.jpg b/docs/assets/hero-academic.jpg new file mode 100644 index 0000000..937a6f1 Binary files /dev/null and b/docs/assets/hero-academic.jpg differ diff --git a/docs/assets/screenshots/01-workbench.png b/docs/assets/screenshots/01-workbench.png new file mode 100644 index 0000000..4929b78 Binary files /dev/null and b/docs/assets/screenshots/01-workbench.png differ diff --git a/docs/assets/screenshots/02-diagrams-panel.png b/docs/assets/screenshots/02-diagrams-panel.png new file mode 100644 index 0000000..e386a95 Binary files /dev/null and b/docs/assets/screenshots/02-diagrams-panel.png differ diff --git a/docs/assets/screenshots/03-parameters-panel.png b/docs/assets/screenshots/03-parameters-panel.png new file mode 100644 index 0000000..8630cab Binary files /dev/null and b/docs/assets/screenshots/03-parameters-panel.png differ diff --git a/docs/assets/screenshots/04-settings.png b/docs/assets/screenshots/04-settings.png new file mode 100644 index 0000000..52b4d92 Binary files /dev/null and b/docs/assets/screenshots/04-settings.png differ diff --git a/docs/assets/screenshots/05-shortcuts.png b/docs/assets/screenshots/05-shortcuts.png new file mode 100644 index 0000000..c4f0ae2 Binary files /dev/null and b/docs/assets/screenshots/05-shortcuts.png differ diff --git a/docs/assets/screenshots/07-lab-mode.png b/docs/assets/screenshots/07-lab-mode.png new file mode 100644 index 0000000..347fef2 Binary files /dev/null and b/docs/assets/screenshots/07-lab-mode.png differ diff --git a/docs/assets/screenshots/08-cells-panel.png b/docs/assets/screenshots/08-cells-panel.png new file mode 100644 index 0000000..f702abd Binary files /dev/null and b/docs/assets/screenshots/08-cells-panel.png differ diff --git a/docs/assets/ui-workbench.jpg b/docs/assets/ui-workbench.jpg new file mode 100644 index 0000000..4ad1864 Binary files /dev/null and b/docs/assets/ui-workbench.jpg differ diff --git a/scripts/capture_ui_screenshots.mjs b/scripts/capture_ui_screenshots.mjs new file mode 100644 index 0000000..65c58b9 --- /dev/null +++ b/scripts/capture_ui_screenshots.mjs @@ -0,0 +1,184 @@ +/** + * Capturas reales de la UI de lablog (Playwright + API real). + * Uso: node scripts/capture_ui_screenshots.mjs + * Requiere API :8000 y Vite :5173. + */ +import { chromium } from '../ui/node_modules/playwright/index.mjs' +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const OUT = join(ROOT, 'docs/assets/screenshots') +const BASE = process.env.LABLOG_UI_URL || 'http://127.0.0.1:5173' +const API = process.env.LABLOG_API_URL || 'http://127.0.0.1:8000/api/v1' + +mkdirSync(OUT, { recursive: true }) + +const LATEX_DEMO = String.raw`% lablog-diagram: preset=rc_series_charge version=1 +% lablog-param: R=1000 +% lablog-param: C=1e-06 +% lablog-param: V0=5 +\section{Sesión RC} +La constante de tiempo es $\tau = RC$. +\begin{equation} + v_C(t) = V_0\left(1 - e^{-t/RC}\right) +\end{equation} +` + +async function api(path, init = {}) { + const res = await fetch(`${API}${path}`, { + headers: { 'Content-Type': 'application/json', ...(init.headers || {}) }, + ...init, + }) + if (!res.ok) { + const t = await res.text() + throw new Error(`${init.method || 'GET'} ${path} -> ${res.status} ${t}`) + } + if (res.status === 204) return null + const text = await res.text() + return text ? JSON.parse(text) : null +} + +async function seed() { + const page = await api('/pages', { + method: 'POST', + body: JSON.stringify({ title: 'RC lab session', project_id: 'optics-bench' }), + }) + const id = page.page_id + const detail = await api(`/pages/${id}`) + await api(`/pages/${id}`, { + method: 'PUT', + body: JSON.stringify({ raw: LATEX_DEMO, version: detail.version }), + }) + // second empty-ish page for sidebar density + await api('/pages', { + method: 'POST', + body: JSON.stringify({ title: 'Notas de óptica', project_id: 'optics-bench' }), + }) + return id +} + +async function shot(page, name, opts = {}) { + const path = join(OUT, name) + await page.screenshot({ path, fullPage: false, ...opts }) + console.log('wrote', path) +} + +async function main() { + console.log('seeding API…') + const pageId = await seed() + console.log('page', pageId) + + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + colorScheme: 'dark', + }) + const page = await context.newPage() + + await page.addInitScript(() => { + localStorage.setItem('lablog-welcome-dismissed', 'true') + localStorage.setItem('lablog-theme', 'dark') + }) + + await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60_000 }) + // wait shell + await page.locator('button[title="Nueva página"], button[title*="Nueva"]').first().waitFor({ + timeout: 30_000, + }) + // select seeded page if visible + const rc = page.getByText('RC lab session').first() + if (await rc.isVisible().catch(() => false)) { + await rc.click() + await page.waitForTimeout(800) + } + + // 1. Workbench main + await shot(page, '01-workbench.png') + + // 2. Diagrams panel + const diagramsBtn = page.locator('button[title*="Diagrama"], button:has-text("Diagramas")').first() + // sidebar tool buttons use labels + const diagramsTool = page.getByRole('button', { name: /Diagramas|diagrams/i }).first() + if (await diagramsTool.isVisible().catch(() => false)) { + await diagramsTool.click() + await page.waitForTimeout(600) + await shot(page, '02-diagrams-panel.png') + } else { + // try toolbar / panels via shortcut-like click on CircuitBoard + await page.keyboard.press('Meta+Shift+D').catch(() => {}) + await page.keyboard.press('Control+Shift+D').catch(() => {}) + await page.waitForTimeout(500) + await shot(page, '02-diagrams-panel.png') + } + + // 3. Parameters if possible + const paramsTool = page.getByRole('button', { name: /Parámetros|Parameters/i }).first() + if (await paramsTool.isVisible().catch(() => false)) { + await paramsTool.click() + await page.waitForTimeout(500) + await shot(page, '03-parameters-panel.png') + } + + // 4. Settings / shortcuts + const prefs = page + .locator( + 'button[data-testid="settings-trigger"], button[title="Preferencias"], button[aria-label="Preferencias"]', + ) + .first() + if (await prefs.isVisible().catch(() => false)) { + await prefs.click() + await page.waitForTimeout(400) + await shot(page, '04-settings.png') + // open shortcuts section if present + const shortcuts = page.getByText(/Atajos|Shortcuts|Teclado/i).first() + if (await shortcuts.isVisible().catch(() => false)) { + await shortcuts.click().catch(() => {}) + await page.waitForTimeout(300) + await shot(page, '05-shortcuts.png') + } + await page.keyboard.press('Escape') + } + + // 5. Command palette + await page.keyboard.press('Meta+K').catch(() => {}) + await page.keyboard.press('Control+K').catch(() => {}) + await page.waitForTimeout(400) + const palette = page.locator('[cmdk-root], [role="dialog"]').first() + if (await palette.isVisible().catch(() => false)) { + await shot(page, '06-command-palette.png') + await page.keyboard.press('Escape') + } else { + await shot(page, '06-command-palette.png') + } + + // 6. Lab mode + const lab = page.getByRole('button', { name: /Laboratorio|Lab/i }).first() + if (await lab.isVisible().catch(() => false)) { + await lab.click() + await page.waitForTimeout(800) + await shot(page, '07-lab-mode.png') + // back + const back = page.getByRole('button', { name: /Volver al editor|editor/i }).first() + if (await back.isVisible().catch(() => false)) await back.click() + await page.waitForTimeout(500) + } + + // 7. Cells panel + const cells = page.getByRole('button', { name: /Celdas|Cells/i }).first() + if (await cells.isVisible().catch(() => false)) { + await cells.click() + await page.waitForTimeout(500) + await shot(page, '08-cells-panel.png') + } + + await browser.close() + console.log('done →', OUT) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/lablog/api.py b/src/lablog/api.py index 8ae539b..45aa91c 100644 --- a/src/lablog/api.py +++ b/src/lablog/api.py @@ -30,7 +30,7 @@ UnsupportedLanguageError, ) from lablog.config import settings, ui_dist_dir -from lablog.event_store import EventStore +from lablog.event_store import EventStore, VersionConflictError from lablog.events import ( Event, vault_deletion_scheduled, @@ -227,14 +227,18 @@ def get_engine() -> CodeEngine: return _code_engine +_MAX_TITLE_CHARS = 500 +_MAX_PROJECT_ID_CHARS = 128 + + class CreatePageRequest(BaseModel): - title: str = "Sin título" - project_id: str | None = None + title: str = Field(default="Sin título", max_length=_MAX_TITLE_CHARS) + project_id: str | None = Field(default=None, max_length=_MAX_PROJECT_ID_CHARS) class UpdatePageRequest(BaseModel): - title: str | None = None - project_id: str | None = None + title: str | None = Field(default=None, max_length=_MAX_TITLE_CHARS) + project_id: str | None = Field(default=None, max_length=_MAX_PROJECT_ID_CHARS) class MoveCellPayload(BaseModel): @@ -276,10 +280,12 @@ class PageSummary(BaseModel): class PageDetail(BaseModel): page_id: str title: str + project_id: str | None = None latex: str raw: str ast: list[dict[str, Any]] version: int + updated_at: datetime | None = None class HistoryEntry(BaseModel): @@ -425,8 +431,23 @@ def update_page_raw(page_id: str, req: UpdatePageRawRequest) -> PageDetail: if not _is_valid_page_id(page_id): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"page_id inválido: {page_id}") _require_active_page(page_id) - _check_version(page_id, req.version) - commands.replace_document(store, page_id=page_id, latex=req.raw) + try: + commands.replace_document( + store, + page_id=page_id, + latex=req.raw, + expected_version=req.version, + ) + except VersionConflictError as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "error_code": "VERSION_CONFLICT", + "message": "La página cambió en otro cliente; recarga e inténtalo de nuevo", + "expected": exc.expected, + "current": exc.current, + }, + ) from exc try: return PageDetail(**projections.page_detail(store, page_id)) except PageNotFoundError: @@ -472,8 +493,23 @@ def replace_page(page_id: str, payload: ReplacePayload) -> dict[str, Any]: if not _is_valid_page_id(page_id): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"page_id inválido: {page_id}") _require_active_page(page_id) - _check_version(page_id, payload.version) - commands.replace_document(store, page_id=page_id, latex=payload.latex) + try: + commands.replace_document( + store, + page_id=page_id, + latex=payload.latex, + expected_version=payload.version, + ) + except VersionConflictError as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "error_code": "VERSION_CONFLICT", + "message": "La página cambió en otro cliente; recarga e inténtalo de nuevo", + "expected": exc.expected, + "current": exc.current, + }, + ) from exc try: detail = projections.page_detail(store, page_id) except PageNotFoundError: @@ -547,7 +583,7 @@ def restore_version(page_id: str, event_index: int) -> PageDetail: @router.post("/pages/{page_id}/cells", status_code=status.HTTP_201_CREATED) -def insert_cell(page_id: str, payload: CellPayload) -> dict[str, str]: +def insert_cell(page_id: str, payload: CellPayload) -> dict[str, Any]: if not _is_valid_page_id(page_id): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"page_id inválido: {page_id}") _require_active_page(page_id) @@ -558,11 +594,11 @@ def insert_cell(page_id: str, payload: CellPayload) -> dict[str, str]: language=payload.language, source=payload.source, ) - return {"status": "ok"} + return {"status": "ok", "version": len(store.get_events(page_id))} @router.post("/pages/{page_id}/cells/{cell_id}/update", status_code=status.HTTP_200_OK) -def update_cell(page_id: str, cell_id: str, payload: UpdateCellPayload) -> dict[str, str]: +def update_cell(page_id: str, cell_id: str, payload: UpdateCellPayload) -> dict[str, Any]: if not _is_valid_page_id(page_id): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"page_id inválido: {page_id}") _require_active_page(page_id) @@ -573,7 +609,7 @@ def update_cell(page_id: str, cell_id: str, payload: UpdateCellPayload) -> dict[ language=payload.language, source=payload.source, ) - return {"status": "ok"} + return {"status": "ok", "version": len(store.get_events(page_id))} @router.post("/pages/{page_id}/cells/{cell_id}/execute", status_code=status.HTTP_200_OK) @@ -627,12 +663,12 @@ def delete_cell(page_id: str, cell_id: str) -> None: @router.post("/pages/{page_id}/cells/{cell_id}/move", status_code=status.HTTP_200_OK) -def move_cell(page_id: str, cell_id: str, payload: MoveCellPayload) -> dict[str, str]: +def move_cell(page_id: str, cell_id: str, payload: MoveCellPayload) -> dict[str, Any]: if not _is_valid_page_id(page_id): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"page_id inválido: {page_id}") _require_active_page(page_id) commands.move_cell(store, page_id=page_id, cell_id=cell_id, new_index=payload.new_index) - return {"status": "ok"} + return {"status": "ok", "version": len(store.get_events(page_id))} @router.get("/pages/{page_id}/cells") diff --git a/src/lablog/commands.py b/src/lablog/commands.py index 1b06a69..87d060b 100644 --- a/src/lablog/commands.py +++ b/src/lablog/commands.py @@ -68,8 +68,17 @@ def delete_page(store: EventStore, page_id: str) -> None: store.append(page_deleted(page_id=page_id)) -def replace_document(store: EventStore, page_id: str, latex: str) -> None: - store.append(document_replaced(page_id=page_id, latex=latex)) +def replace_document( + store: EventStore, + page_id: str, + latex: str, + *, + expected_version: int | None = None, +) -> None: + store.append( + document_replaced(page_id=page_id, latex=latex), + expected_version=expected_version, + ) def insert_text(store: EventStore, page_id: str, position: int, text: str) -> None: diff --git a/src/lablog/config.py b/src/lablog/config.py index 42ecabc..0f1daf2 100644 --- a/src/lablog/config.py +++ b/src/lablog/config.py @@ -41,7 +41,12 @@ def __init__(self) -> None: os.getenv("LABLOG_DATA_DIR", Path.home() / ".lablog") ).expanduser().resolve() self.host = os.getenv("LABLOG_HOST", "127.0.0.1") - self.port = int(os.getenv("LABLOG_PORT", "8000")) + try: + self.port = int(os.getenv("LABLOG_PORT", "8000")) + except ValueError: + self.port = 8000 + if not (1 <= self.port <= 65535): + self.port = 8000 _cors = os.getenv( "LABLOG_CORS_ORIGINS", diff --git a/src/lablog/diagrams/expand.py b/src/lablog/diagrams/expand.py index 4485345..92c03c5 100644 --- a/src/lablog/diagrams/expand.py +++ b/src/lablog/diagrams/expand.py @@ -154,8 +154,10 @@ def colorize_named_component(latex: str, tikz_name: str, latex_color: str) -> st ) def repl(m: re.Match[str]) -> str: - # Busca hacia atrás en el mismo [...] si ya hay color= - start = max(0, m.start() - 80) + # Solo mira dentro del mismo bloque de opciones [...] / {...} + # (no el color= de un componente anterior en la misma línea). + open_br = max(latex.rfind("[", 0, m.start()), latex.rfind("{", 0, m.start())) + start = open_br + 1 if open_br >= 0 else max(0, m.start() - 40) window = latex[start : m.end()] if re.search(r"\bcolor\s*=", window): return m.group(0) diff --git a/src/lablog/event_store.py b/src/lablog/event_store.py index 3400f30..af34d86 100644 --- a/src/lablog/event_store.py +++ b/src/lablog/event_store.py @@ -15,6 +15,15 @@ _SAFE_PAGE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$") +class VersionConflictError(Exception): + """El log no tiene la versión esperada al intentar un append condicional.""" + + def __init__(self, expected: int, current: int) -> None: + self.expected = expected + self.current = current + super().__init__(f"version conflict: expected={expected} current={current}") + + class EventStore: """Almacén inmutable de eventos por página.""" @@ -37,19 +46,42 @@ def _lock_for(self, page_id: str) -> threading.Lock: self._locks[page_id] = lock return lock - def append(self, event: Event) -> None: + def append(self, event: Event, *, expected_version: int | None = None) -> int: """Añade un evento al final del log de la página. Escribe la línea completa y hace fsync para reducir el riesgo de eventos truncados si el proceso muere a mitad del write. Lock por página: serializa appends concurrentes (autosave + execute). + + Si ``expected_version`` no es None, comprueba atómicamente (bajo el + mismo lock) que el log tiene esa longitud antes de escribir. + Devuelve la nueva versión (número de eventos tras el append). """ page_file = self._page_file(event.page_id) line = event.model_dump_json() + "\n" - with self._lock_for(event.page_id), page_file.open("a", encoding="utf-8") as f: - f.write(line) - f.flush() - os.fsync(f.fileno()) + with self._lock_for(event.page_id): + if expected_version is not None: + current = self._count_events_unlocked(page_file) + if current != expected_version: + raise VersionConflictError(expected_version, current) + with page_file.open("a", encoding="utf-8") as f: + f.write(line) + f.flush() + os.fsync(f.fileno()) + if expected_version is not None: + return expected_version + 1 + return self._count_events_unlocked(page_file) + + @staticmethod + def _count_events_unlocked(page_file: Path) -> int: + if not page_file.exists(): + return 0 + count = 0 + with page_file.open("r", encoding="utf-8") as f: + for line in f: + if line.strip(): + count += 1 + return count def get_events(self, page_id: str) -> list[Event]: """Devuelve todos los eventos de una página en orden. diff --git a/src/lablog/projections.py b/src/lablog/projections.py index e38ec8b..8cfdf75 100644 --- a/src/lablog/projections.py +++ b/src/lablog/projections.py @@ -54,10 +54,12 @@ def page_detail(store: EventStore, page_id: str) -> dict[str, Any]: return { "page_id": page_id, "title": proj.title, + "project_id": proj.project_id, "latex": latex, "raw": latex, "ast": [node_to_json(child) for child in proj.ast.children], "version": len(events), + "updated_at": events[-1].timestamp, } @@ -120,10 +122,12 @@ def page_at(store: EventStore, page_id: str, event_index: int) -> dict[str, Any] return { "page_id": page_id, "title": proj.title, + "project_id": proj.project_id, "latex": latex, "raw": latex, "ast": [node_to_json(child) for child in proj.ast.children], "version": idx + 1, + "updated_at": events[idx].timestamp, } diff --git a/src/lablog/snippets.py b/src/lablog/snippets.py index b74ecf6..45a09c0 100644 --- a/src/lablog/snippets.py +++ b/src/lablog/snippets.py @@ -221,7 +221,7 @@ def _p( y_fit = np.polyval(coeffs, x_fit) plt.figure() plt.scatter(x, y, label='Datos') -plt.plot(x_fit, y_fit, color='{color}', label=f'y = {coeffs[0]:.2f}x + {coeffs[1]:.2f}') +plt.plot(x_fit, y_fit, color='{color}', label=f'y = {{coeffs[0]:.2f}}x + {{coeffs[1]:.2f}}') plt.xlabel('{xlabel}') plt.ylabel('{ylabel}') plt.title('{title}') @@ -248,7 +248,7 @@ def _p( name="Tabla simple (LaTeX)", category="tikz", description="Tabla básica con booktabs.", - template="""\\\\begin{{table}}[h] + template="""\\begin{{table}}[h] \\centering \\begin{{tabular}}{{ {cols} }} \\toprule diff --git a/tests/test_diagrams.py b/tests/test_diagrams.py index 6b57d7a..1030c11 100644 --- a/tests/test_diagrams.py +++ b/tests/test_diagrams.py @@ -160,6 +160,12 @@ def test_tikz_color_highlight_and_pyspice_source() -> None: colored = colorize_named_component(raw, "R1", "orange") assert "color=orange,name=R1" in colored.replace(" ", "") + # color= de un componente anterior no bloquea al siguiente en la misma línea + multi = r"\draw (0,0) to[R, color=red, name=R2] (1,0) to[R, name=R1] (2,0);" + multi_c = colorize_named_component(multi, "R1", "orange") + assert "color=orange,name=R1" in multi_c.replace(" ", "") + assert "color=red,name=R2" in multi_c.replace(" ", "") or "color=red, name=R2" in multi_c + preset = get_preset("rc_series_charge") assert preset is not None out = expand_preset(preset, highlight_param="R") diff --git a/tests/test_prod_hardening.py b/tests/test_prod_hardening.py index fd401d3..a67c655 100644 --- a/tests/test_prod_hardening.py +++ b/tests/test_prod_hardening.py @@ -52,6 +52,73 @@ def test_version_conflict_on_put() -> None: assert r.json()["detail"]["error_code"] == "VERSION_CONFLICT" +def test_insert_and_update_cell_return_version() -> None: + pid = client.post("/api/v1/pages", json={"title": "Cells"}).json()["page_id"] + before = client.get(f"/api/v1/pages/{pid}").json()["version"] + r = client.post( + f"/api/v1/pages/{pid}/cells", + json={"cell_id": "c1", "language": "python", "source": "1"}, + ) + assert r.status_code == 201 + assert r.json()["version"] == before + 1 + r2 = client.post( + f"/api/v1/pages/{pid}/cells/c1/update", + json={"language": "python", "source": "2"}, + ) + assert r2.status_code == 200 + assert r2.json()["version"] == before + 2 + + +def test_atomic_version_conflict_on_concurrent_replace(tmp_path: Path) -> None: + """OCC debe fallar atómicamente bajo el lock del EventStore.""" + from lablog.commands import replace_document + from lablog.event_store import EventStore, VersionConflictError + from lablog.events import page_created + + store = EventStore(tmp_path) + pid = "page-atomic-1" + store.append(page_created(page_id=pid, title="A")) + assert len(store.get_events(pid)) == 1 + replace_document(store, pid, "v1", expected_version=1) + assert len(store.get_events(pid)) == 2 + try: + replace_document(store, pid, "stale", expected_version=1) + raise AssertionError("expected VersionConflictError") + except VersionConflictError as exc: + assert exc.expected == 1 + assert exc.current == 2 + + +def test_create_page_rejects_huge_title_and_project_id() -> None: + huge = "x" * 10_000 + r = client.post("/api/v1/pages", json={"title": huge}) + assert r.status_code == 422 + r = client.post("/api/v1/pages", json={"title": "ok", "project_id": "p" * 500}) + assert r.status_code == 422 + + +def test_page_detail_preserves_project_id_and_updated_at() -> None: + """getPage no debe borrar project_id: el detail incluye metadatos de list.""" + res = client.post( + "/api/v1/pages", + json={"title": "Proyecto", "project_id": "lab-optics"}, + ) + assert res.status_code == 201 + pid = res.json()["page_id"] + assert res.json()["project_id"] == "lab-optics" + + detail = client.get(f"/api/v1/pages/{pid}").json() + assert detail["project_id"] == "lab-optics" + assert detail["title"] == "Proyecto" + assert detail.get("updated_at") is not None + + # Tras PUT raw se conserva el project_id + ver = detail["version"] + put = client.put(f"/api/v1/pages/{pid}", json={"raw": "x=1", "version": ver}) + assert put.status_code == 200 + assert put.json()["project_id"] == "lab-optics" + + def test_document_env_does_not_swallow_python_cells() -> None: src = r"""\documentclass{article} \begin{document} diff --git a/tests/test_snippets.py b/tests/test_snippets.py index ed9fb24..643e23c 100644 --- a/tests/test_snippets.py +++ b/tests/test_snippets.py @@ -38,3 +38,20 @@ def test_catalog_has_parameters() -> None: assert snippet.parameters for param in snippet.parameters: assert param.description + + +def test_every_catalog_snippet_renders_with_defaults() -> None: + """Regresión: f-strings / braces mal escapados rompen .format (fit_line).""" + for snippet in Snippet.catalog(): + code = render_snippet(snippet, {}) + assert isinstance(code, str) and code.strip() + fit = find_snippet("fit_line") + assert fit is not None + fit_code = render_snippet(fit, {}) + assert "coeffs[0]" in fit_code + assert "f'y = {coeffs[0]:.2f}" in fit_code or "coeffs[0]:.2f" in fit_code + table = find_snippet("simple_table") + assert table is not None + table_code = render_snippet(table, {}) + assert table_code.lstrip().startswith(r"\begin{table}") + assert not table_code.lstrip().startswith(r"\\begin") diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6dc8dd2..0ba9eb3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -52,7 +52,10 @@ function AppInitializer() { } catch { // localStorage no disponible } - if (savedScale) setFontScale(Number(savedScale)) + if (savedScale) { + const n = Number(savedScale) + if (Number.isFinite(n) && n >= 70 && n <= 150) setFontScale(n) + } if (savedAccent) { setAccent(savedAccent) if (savedAccent === 'zinc') { diff --git a/ui/src/components/editor/latex-editor.tsx b/ui/src/components/editor/latex-editor.tsx index 92605d8..c8994d3 100644 --- a/ui/src/components/editor/latex-editor.tsx +++ b/ui/src/components/editor/latex-editor.tsx @@ -89,10 +89,11 @@ export function LatexEditor() { [setActiveAst, setActiveVersion], ) - const getVersion = useCallback( - () => useAppStore.getState().activeVersion || undefined, - [], - ) + const getVersion = useCallback(() => { + // Incluye 0: `|| undefined` saltaba OCC al cambiar de página. + const v = useAppStore.getState().activeVersion + return typeof v === 'number' ? v : undefined + }, []) const { status, updateRaw, flush, discardPending } = usePageUpdate( activePageId, @@ -140,10 +141,11 @@ export function LatexEditor() { getPage(requestedId) .then((page) => { if (cancelled || useAppStore.getState().activePageId !== requestedId) return - setActiveLatex(page.latex) + const text = page.raw || page.latex + setActiveLatex(text) setActiveAst(page.ast) setActiveVersion(page.version) - resetHistory(page.latex) + resetHistory(text) }) .catch(() => { if (cancelled || useAppStore.getState().activePageId !== requestedId) return diff --git a/ui/src/components/history/time-travel.tsx b/ui/src/components/history/time-travel.tsx index ff8a7d1..f08d6fb 100644 --- a/ui/src/components/history/time-travel.tsx +++ b/ui/src/components/history/time-travel.tsx @@ -20,6 +20,7 @@ export function TimeTravelOverlay({ pageId, onClose }: TimeTravelOverlayProps) { const activeLatex = useAppStore((s) => s.activeLatex) const setActiveLatex = useAppStore((s) => s.setActiveLatex) const setActiveAst = useAppStore((s) => s.setActiveAst) + const setActiveVersion = useAppStore((s) => s.setActiveVersion) const flushSave = useAppStore((s) => s.flushSave) const [history, setHistory] = useState([]) const [index, setIndex] = useState(0) @@ -67,8 +68,9 @@ export function TimeTravelOverlay({ pageId, onClose }: TimeTravelOverlayProps) { try { if (flushSave) await flushSave() const page = await restoreVersion(pageId, index) - setActiveLatex(page.latex) + setActiveLatex(page.raw || page.latex) setActiveAst(page.ast) + setActiveVersion(page.version) toast.success(`Versión del evento ${index} restaurada`) onClose() } catch { diff --git a/ui/src/components/lab/lab-canvas.test.tsx b/ui/src/components/lab/lab-canvas.test.tsx index 0c6f297..0fd36b9 100644 --- a/ui/src/components/lab/lab-canvas.test.tsx +++ b/ui/src/components/lab/lab-canvas.test.tsx @@ -10,24 +10,41 @@ vi.mock('@/lib/api', async (importOriginal) => { ...actual, deleteCell: vi.fn(() => Promise.resolve()), executeCell: vi.fn(), + getPage: vi.fn(() => + Promise.resolve({ + id: 'page-1', + title: 'T', + project_id: null, + latex: '', + raw: '', + ast: [], + version: 2, + updated_at: new Date().toISOString(), + }), + ), insertCell: vi.fn(() => Promise.resolve()), listCells: vi.fn(), moveCell: vi.fn(() => Promise.resolve()), - updateCell: vi.fn(() => Promise.resolve()), + updateCell: vi.fn(() => Promise.resolve({ version: 3 })), } }) -import { executeCell, listCells } from '@/lib/api' +import { executeCell, getPage, listCells, updateCell } from '@/lib/api' import { LabCanvas } from './lab-canvas' const mockExecuteCell = vi.mocked(executeCell) const mockListCells = vi.mocked(listCells) +const mockUpdateCell = vi.mocked(updateCell) +const mockGetPage = vi.mocked(getPage) describe('LabCanvas', () => { beforeEach(() => { mockExecuteCell.mockReset() mockListCells.mockReset() - useAppStore.setState({ activePageId: 'page-1' }) + mockUpdateCell.mockReset() + mockUpdateCell.mockResolvedValue({ version: 3 }) + mockGetPage.mockClear() + useAppStore.setState({ activePageId: 'page-1', labMode: true, flushLabCells: null }) }) it('renders markdown without executing inline HTML scripts', async () => { @@ -75,4 +92,35 @@ describe('LabCanvas', () => { expect(screen.getByText(/kernel timeout/i)).toBeInTheDocument() }) }) + + it('flushes dirty cell source when leaving lab without blur', async () => { + mockListCells.mockResolvedValue([ + { + cell_id: 'c1', + language: 'python', + source: 'print(1)', + output: '', + figure_path: null, + }, + ]) + + render() + await waitFor(() => expect(mockListCells).toHaveBeenCalledWith('page-1')) + + const ta = screen.getByPlaceholderText('# código Python') + await userEvent.clear(ta) + await userEvent.type(ta, 'print(42)') + + // Sin blur: el snapshot local está dirty. + await userEvent.click(screen.getByRole('button', { name: /Volver al editor/i })) + + await waitFor(() => { + expect(mockUpdateCell).toHaveBeenCalledWith('page-1', 'c1', { + language: 'python', + source: 'print(42)', + }) + }) + expect(mockGetPage).toHaveBeenCalledWith('page-1') + expect(useAppStore.getState().labMode).toBe(false) + }) }) diff --git a/ui/src/components/lab/lab-canvas.tsx b/ui/src/components/lab/lab-canvas.tsx index c51aee5..ab843b3 100644 --- a/ui/src/components/lab/lab-canvas.tsx +++ b/ui/src/components/lab/lab-canvas.tsx @@ -9,7 +9,7 @@ import { RotateCcw, Trash2, } from 'lucide-react' -import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' +import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -18,6 +18,7 @@ import { ApiError, deleteCell, executeCell, + getPage, insertCell, listCells, moveCell as moveCellApi, @@ -25,6 +26,10 @@ import { } from '@/lib/api' import { useAppStore } from '@/stores/app-store' +function cellFingerprint(language: string, source: string): string { + return `${language}\0${source}` +} + type LabCell = { cell_id: string language: string @@ -94,13 +99,24 @@ function MarkdownPreview({ source }: { source: string }) { export function LabCanvas() { const activePageId = useAppStore((s) => s.activePageId) const setLabMode = useAppStore((s) => s.setLabMode) + const setFlushLabCells = useAppStore((s) => s.setFlushLabCells) + const setActiveLatex = useAppStore((s) => s.setActiveLatex) + const setActiveAst = useAppStore((s) => s.setActiveAst) + const setActiveVersion = useAppStore((s) => s.setActiveVersion) const [cells, setCells] = useState([]) const [loading, setLoading] = useState(false) const bottomRef = useRef(null) + /** Snapshot language+source ya persistidos en el servidor. */ + const savedRef = useRef>({}) + const cellsRef = useRef(cells) + const pageIdRef = useRef(activePageId) + cellsRef.current = cells + pageIdRef.current = activePageId useEffect(() => { if (!activePageId) { setCells([]) + savedRef.current = {} return } let cancelled = false @@ -109,13 +125,23 @@ export function LabCanvas() { listCells(requestedId) .then((serverCells) => { if (cancelled || useAppStore.getState().activePageId !== requestedId) return - setCells( - serverCells.map((c) => ({ - ...c, - status: (c.status as LabCell['status']) ?? 'idle', - collapsed: false, - })), - ) + const mapped = serverCells.map((c) => ({ + ...c, + status: (c.status as LabCell['status']) ?? 'idle', + collapsed: false, + })) + const snap: Record = {} + for (const c of mapped) { + snap[c.cell_id] = cellFingerprint(c.language, c.source) + } + savedRef.current = snap + setCells(mapped) + }) + .catch((err) => { + if (cancelled) return + console.error(err) + setCells([]) + savedRef.current = {} }) .finally(() => { if (!cancelled) setLoading(false) @@ -125,6 +151,79 @@ export function LabCanvas() { } }, [activePageId]) + const saveCell = useCallback(async (cellId: string, language: string, source: string) => { + const pageId = pageIdRef.current + if (!pageId) return + const res = await updateCell(pageId, cellId, { language, source }) + savedRef.current[cellId] = cellFingerprint(language, source) + if (res?.version && res.version > 0) setActiveVersion(res.version) + }, [setActiveVersion]) + + const flushDirtyCells = useCallback(async () => { + const pageId = pageIdRef.current + if (!pageId) return + const dirty = cellsRef.current.filter( + (c) => savedRef.current[c.cell_id] !== cellFingerprint(c.language, c.source), + ) + if (dirty.length === 0) { + // Aun sin dirty: resync versión por si se insertaron/ejecutaron celdas. + try { + const page = await getPage(pageId) + setActiveLatex(page.raw || page.latex) + setActiveAst(page.ast) + setActiveVersion(page.version) + } catch (err) { + console.error(err) + } + return + } + await Promise.all( + dirty.map((c) => + updateCell(pageId, c.cell_id, { language: c.language, source: c.source }).then(() => { + savedRef.current[c.cell_id] = cellFingerprint(c.language, c.source) + }), + ), + ) + try { + const page = await getPage(pageId) + setActiveLatex(page.raw || page.latex) + setActiveAst(page.ast) + setActiveVersion(page.version) + } catch (err) { + console.error(err) + } + }, [setActiveLatex, setActiveAst, setActiveVersion]) + + useEffect(() => { + setFlushLabCells(flushDirtyCells) + return () => setFlushLabCells(null) + }, [flushDirtyCells, setFlushLabCells]) + + // Al desmontar (cambio de página o salir del lab): best-effort persist. + useEffect(() => { + return () => { + const pageId = pageIdRef.current + if (!pageId) return + const dirty = cellsRef.current.filter( + (c) => savedRef.current[c.cell_id] !== cellFingerprint(c.language, c.source), + ) + for (const c of dirty) { + void updateCell(pageId, c.cell_id, { language: c.language, source: c.source }).catch( + (err) => console.error(err), + ) + } + } + }, []) + + const leaveLab = async () => { + try { + await flushDirtyCells() + } catch (err) { + console.error(err) + } + setLabMode(false) + } + const addCell = async (language: string) => { if (!activePageId) return const cell: LabCell = { @@ -137,11 +236,13 @@ export function LabCanvas() { collapsed: false, } try { - await insertCell(activePageId, { + const res = await insertCell(activePageId, { cell_id: cell.cell_id, language: cell.language, source: cell.source, }) + savedRef.current[cell.cell_id] = cellFingerprint(cell.language, cell.source) + if (res?.version && res.version > 0) setActiveVersion(res.version) setCells((prev) => [...prev, cell]) setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50) } catch (err) { @@ -153,18 +254,13 @@ export function LabCanvas() { setCells((prev) => prev.map((c) => (c.cell_id === cellId ? { ...c, source } : c))) } - const saveCell = async (cellId: string, language: string, source: string) => { - if (!activePageId) return - await updateCell(activePageId, cellId, { language, source }) - } - const runCell = async (cellId: string) => { if (!activePageId) return const cell = cells.find((c) => c.cell_id === cellId) if (!cell) return - await saveCell(cellId, cell.language, cell.source) setCells((prev) => prev.map((c) => (c.cell_id === cellId ? { ...c, status: 'running' } : c))) try { + await saveCell(cellId, cell.language, cell.source) const result = await executeCell(activePageId, cellId) setCells((prev) => prev.map((c) => @@ -193,8 +289,12 @@ export function LabCanvas() { const removeCell = async (cellId: string) => { if (!activePageId) return - await deleteCell(activePageId, cellId) - setCells((prev) => prev.filter((c) => c.cell_id !== cellId)) + try { + await deleteCell(activePageId, cellId) + setCells((prev) => prev.filter((c) => c.cell_id !== cellId)) + } catch (err) { + console.error(err) + } } const moveCell = async (index: number, direction: -1 | 1) => { @@ -202,13 +302,18 @@ export function LabCanvas() { const newIndex = index + direction if (newIndex < 0 || newIndex >= cells.length) return const cellId = cells[index].cell_id - await moveCellApi(activePageId, cellId, newIndex) - setCells((prev) => { - const next = [...prev] - const [moved] = next.splice(index, 1) - next.splice(newIndex, 0, moved) - return next - }) + try { + const res = await moveCellApi(activePageId, cellId, newIndex) + if (res?.version && res.version > 0) setActiveVersion(res.version) + setCells((prev) => { + const next = [...prev] + const [moved] = next.splice(index, 1) + next.splice(newIndex, 0, moved) + return next + }) + } catch (err) { + console.error(err) + } } const toggleCollapse = (cellId: string) => { @@ -229,7 +334,7 @@ export function LabCanvas() { - diff --git a/ui/src/components/panels/cells-panel.tsx b/ui/src/components/panels/cells-panel.tsx index d196dc8..1fc85ae 100644 --- a/ui/src/components/panels/cells-panel.tsx +++ b/ui/src/components/panels/cells-panel.tsx @@ -31,12 +31,16 @@ export function CellsPanel() { const refreshCells = useCallback(async () => { if (!activePageId) return - const [cellList, page] = await Promise.all([listCells(activePageId), getPage(activePageId)]) - setCells(cellList) - setActiveAst(page.ast) - // Sincroniza el raw del editor para que el autosave no pise celdas nuevas. - setActiveLatex(page.raw || page.latex) - setActiveVersion(page.version) + try { + const [cellList, page] = await Promise.all([listCells(activePageId), getPage(activePageId)]) + setCells(cellList) + setActiveAst(page.ast) + // Sincroniza el raw del editor para que el autosave no pise celdas nuevas. + setActiveLatex(page.raw || page.latex) + setActiveVersion(page.version) + } catch (err) { + console.error(err) + } }, [activePageId, setActiveAst, setActiveLatex, setActiveVersion]) useEffect(() => { @@ -44,20 +48,25 @@ export function CellsPanel() { setCells([]) return } - refreshCells() + void refreshCells() }, [activePageId, refreshCells]) const addCell = async () => { if (!activePageId || !newSource.trim()) return - if (flushSave) await flushSave() - const cell = { - cell_id: crypto.randomUUID(), - language: newLang, - source: newSource, + try { + if (flushSave) await flushSave() + const cell = { + cell_id: crypto.randomUUID(), + language: newLang, + source: newSource, + } + await insertCell(activePageId, cell) + await refreshCells() + setNewSource('') + } catch (err) { + console.error(err) + alert(err instanceof Error ? err.message : 'No se pudo añadir la celda') } - await insertCell(activePageId, cell) - await refreshCells() - setNewSource('') } const runCell = async (cellId: string) => { @@ -82,8 +91,13 @@ export function CellsPanel() { const removeCell = async (cellId: string) => { if (!activePageId) return - await deleteCell(activePageId, cellId) - await refreshCells() + try { + await deleteCell(activePageId, cellId) + await refreshCells() + } catch (err) { + console.error(err) + alert(err instanceof Error ? err.message : 'No se pudo eliminar la celda') + } } const handleMove = async (cellId: string, direction: 'up' | 'down') => { @@ -92,8 +106,13 @@ export function CellsPanel() { if (index < 0) return const newIndex = direction === 'up' ? Math.max(0, index - 1) : Math.min(cells.length - 1, index + 1) if (newIndex === index) return - await moveCell(activePageId, cellId, newIndex) - await refreshCells() + try { + await moveCell(activePageId, cellId, newIndex) + await refreshCells() + } catch (err) { + console.error(err) + alert(err instanceof Error ? err.message : 'No se pudo reordenar la celda') + } } return ( diff --git a/ui/src/components/panels/diagrams-panel.tsx b/ui/src/components/panels/diagrams-panel.tsx index 02f4804..efdf286 100644 --- a/ui/src/components/panels/diagrams-panel.tsx +++ b/ui/src/components/panels/diagrams-panel.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input' import { diagramSimulateSource, expandDiagramPreset, + getPage, insertCell, listDiagramPresets, replacePageLatex, @@ -104,9 +105,13 @@ export function DiagramsPanel() { const next = current.trim() ? `${current.trimEnd()}\n\n${result.latex}` : result.latex - setActiveLatex(next) - const version = useAppStore.getState().activeVersion || undefined - const page = await replacePageLatex(activePageId, next, version) + const version = useAppStore.getState().activeVersion + const page = await replacePageLatex( + activePageId, + next, + typeof version === 'number' ? version : undefined, + ) + setActiveLatex(page.latex) setActiveAst(page.ast) setActiveVersion(page.version) clearParameters() @@ -136,9 +141,13 @@ export function DiagramsPanel() { const next = current.trim() ? `${current.trimEnd()}\n\n${result.latex}` : result.latex - setActiveLatex(next) - const version = useAppStore.getState().activeVersion || undefined - const page = await replacePageLatex(activePageId, next, version) + const version = useAppStore.getState().activeVersion + const page = await replacePageLatex( + activePageId, + next, + typeof version === 'number' ? version : undefined, + ) + setActiveLatex(page.latex) setActiveAst(page.ast) setActiveVersion(page.version) clearParameters() @@ -151,6 +160,11 @@ export function DiagramsPanel() { language: 'python', source: sim.source, }) + // insertCell avanza la versión del event log; resincroniza OCC. + const refreshed = await getPage(activePageId) + setActiveLatex(refreshed.raw || refreshed.latex) + setActiveAst(refreshed.ast) + setActiveVersion(refreshed.version) setPanel('cells', true) toast.success(`${result.title}: diagrama + celda de simulación`) } catch (err) { @@ -175,9 +189,13 @@ export function DiagramsPanel() { const next = current.trim() ? `${current.trimEnd()}\n\n${result.latex}` : result.latex - setActiveLatex(next) - const version = useAppStore.getState().activeVersion || undefined - const page = await replacePageLatex(activePageId, next, version) + const version = useAppStore.getState().activeVersion + const page = await replacePageLatex( + activePageId, + next, + typeof version === 'number' ? version : undefined, + ) + setActiveLatex(page.latex) setActiveAst(page.ast) setActiveVersion(page.version) clearParameters() @@ -189,6 +207,10 @@ export function DiagramsPanel() { language: 'python', source: sim.source, }) + const refreshed = await getPage(activePageId) + setActiveLatex(refreshed.raw || refreshed.latex) + setActiveAst(refreshed.ast) + setActiveVersion(refreshed.version) setPanel('cells', true) toast.success(`${result.title}: diagrama + sim PySpice (fallback numpy)`) } catch (err) { diff --git a/ui/src/components/panels/parameters-panel.tsx b/ui/src/components/panels/parameters-panel.tsx index 91f1217..eda967d 100644 --- a/ui/src/components/panels/parameters-panel.tsx +++ b/ui/src/components/panels/parameters-panel.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input' import { applyDiagramParams, diagramSimulateSource, + getPage, insertCell, replacePageLatex, type DiagramExpandResult, @@ -211,12 +212,21 @@ export function ParametersPanel() { const value = parameterValues[name] ?? parameterHints[name]?.default ?? `{{${name}}}` next = next.replaceAll(`{{${name}}}`, value) } - setActiveLatex(next) - if (activePageId) { + if (!activePageId) { + setActiveLatex(next) + return + } + try { + // Persiste primero; solo refleja en la UI si el servidor lo aceptó. const version = useAppStore.getState().activeVersion || undefined const result = await replacePageLatex(activePageId, next, version) + setActiveLatex(result.latex) setActiveAst(result.ast) setActiveVersion(result.version) + } catch (err) { + console.error(err) + toast.error('No se pudieron congelar los parámetros') + throw err } } @@ -235,9 +245,13 @@ export function ParametersPanel() { activeDiagramPresetId ?? undefined, { highlightParam: highlight ?? activeHighlightParam }, ) - setActiveLatex(applied.document_latex) - const version = useAppStore.getState().activeVersion || undefined - const page = await replacePageLatex(activePageId, applied.document_latex, version) + const version = useAppStore.getState().activeVersion + const page = await replacePageLatex( + activePageId, + applied.document_latex, + typeof version === 'number' ? version : undefined, + ) + setActiveLatex(page.latex) setActiveAst(page.ast) setActiveVersion(page.version) setActiveDiagramPresetId(applied.preset_id) @@ -254,6 +268,10 @@ export function ParametersPanel() { language: 'python', source: sim.source, }) + const refreshed = await getPage(activePageId) + setActiveLatex(refreshed.raw || refreshed.latex) + setActiveAst(refreshed.ast) + setActiveVersion(refreshed.version) setPanel('cells', true) toast.success('Diagrama actualizado + nueva celda de simulación') } else { @@ -268,16 +286,21 @@ export function ParametersPanel() { } const onPrimaryAction = async () => { - if (placeholderMatches.length > 0 && !hasDiagramMarkers) { + try { + if (placeholderMatches.length > 0 && !hasDiagramMarkers) { + await bakePlaceholders() + toast.success('Valores congelados') + return + } + if (isDiagramMode) { + await reapplyDiagram(false) + return + } await bakePlaceholders() toast.success('Valores congelados') - return - } - if (isDiagramMode) { - await reapplyDiagram(false) - return + } catch { + // toast de error ya emitido en bakePlaceholders / reapplyDiagram } - await bakePlaceholders() } return ( diff --git a/ui/src/components/panels/snippets-panel.tsx b/ui/src/components/panels/snippets-panel.tsx index a7afc9a..2ff7558 100644 --- a/ui/src/components/panels/snippets-panel.tsx +++ b/ui/src/components/panels/snippets-panel.tsx @@ -83,6 +83,8 @@ function ParameterField({ function SnippetCard({ snippet }: { snippet: Snippet }) { const activeLatex = useAppStore((s) => s.activeLatex) const setActiveLatex = useAppStore((s) => s.setActiveLatex) + const setActiveAst = useAppStore((s) => s.setActiveAst) + const setActiveVersion = useAppStore((s) => s.setActiveVersion) const activePageId = useAppStore((s) => s.activePageId) const setPanel = useAppStore((s) => s.setPanel) const setParameterHints = useAppStore((s) => s.setParameterHints) @@ -136,8 +138,24 @@ function SnippetCard({ snippet }: { snippet: Snippet }) { insertAtCursor(code) } else { const next = activeLatex ? `${activeLatex}\n${code}` : code - setActiveLatex(next) - if (activePageId) await replacePageLatex(activePageId, next) + if (activePageId) { + try { + const version = useAppStore.getState().activeVersion + const page = await replacePageLatex( + activePageId, + next, + typeof version === 'number' ? version : undefined, + ) + setActiveLatex(page.latex) + setActiveAst(page.ast) + setActiveVersion(page.version) + } catch (err) { + console.error(err) + return + } + } else { + setActiveLatex(next) + } } setOpen(false) setPanel('snippets', false) diff --git a/ui/src/components/shell/app-shell.tsx b/ui/src/components/shell/app-shell.tsx index e864c00..d6955e6 100644 --- a/ui/src/components/shell/app-shell.tsx +++ b/ui/src/components/shell/app-shell.tsx @@ -75,33 +75,48 @@ export function AppShell() { useEffect(() => { async function load() { - const [pages, vaultFiles, snippets, symbols, favorites] = await Promise.all([ + // allSettled: un endpoint caído no tumba el arranque completo. + const results = await Promise.allSettled([ listPages(), listVaultFiles(), listSnippets(), listSymbols(), listFavorites(), ]) - setPages(pages) - setVaultFiles(vaultFiles) - setSnippets(snippets) - setSymbols(symbols) - setFavorites(favorites) - if (pages.length > 0 && !useAppStore.getState().activePageId) { - setActivePageId(pages[0].id) + const [pagesR, vaultR, snippetsR, symbolsR, favoritesR] = results + if (pagesR.status === 'fulfilled') { + setPages(pagesR.value) + if (pagesR.value.length > 0 && !useAppStore.getState().activePageId) { + setActivePageId(pagesR.value[0].id) + } + } else { + console.error('load pages', pagesR.reason) } + if (vaultR.status === 'fulfilled') setVaultFiles(vaultR.value) + else console.error('load vault', vaultR.reason) + if (snippetsR.status === 'fulfilled') setSnippets(snippetsR.value) + else console.error('load snippets', snippetsR.reason) + if (symbolsR.status === 'fulfilled') setSymbols(symbolsR.value) + else console.error('load symbols', symbolsR.reason) + if (favoritesR.status === 'fulfilled') setFavorites(favoritesR.value) + else console.error('load favorites', favoritesR.reason) } - load() + void load() }, [setPages, setVaultFiles, setSnippets, setSymbols, setFavorites, setActivePageId]) const refreshPages = async () => { - const pages = await listPages() - setPages(pages) - const currentActivePageId = useAppStore.getState().activePageId - if (pages.length > 0 && !pages.find((p) => p.id === currentActivePageId)) { - setActivePageId(pages[0].id) - } else if (pages.length === 0) { - setActivePageId(null) + try { + const pages = await listPages() + setPages(pages) + const currentActivePageId = useAppStore.getState().activePageId + if (pages.length > 0 && !pages.find((p) => p.id === currentActivePageId)) { + setActivePageId(pages[0].id) + } else if (pages.length === 0) { + setActivePageId(null) + } + } catch (err) { + console.error(err) + alert('No se pudieron recargar las páginas. ¿Está corriendo el backend?') } } diff --git a/ui/src/components/shell/command-palette.tsx b/ui/src/components/shell/command-palette.tsx index 8180876..54c23da 100644 --- a/ui/src/components/shell/command-palette.tsx +++ b/ui/src/components/shell/command-palette.tsx @@ -135,7 +135,15 @@ export function CommandPalette() { Modo laboratorio { + onSelect={async () => { + const flushLab = useAppStore.getState().flushLabCells + if (flushLab) { + try { + await flushLab() + } catch { + /* best-effort */ + } + } setLabMode(false) close() }} diff --git a/ui/src/components/shell/export-menu.tsx b/ui/src/components/shell/export-menu.tsx index 1e2aa03..f1fbcca 100644 --- a/ui/src/components/shell/export-menu.tsx +++ b/ui/src/components/shell/export-menu.tsx @@ -35,6 +35,8 @@ export function ExportMenu() { const activeLatex = useAppStore((s) => s.activeLatex) const parameterValues = useAppStore((s) => s.parameterValues) const setActiveLatex = useAppStore((s) => s.setActiveLatex) + const setActiveAst = useAppStore((s) => s.setActiveAst) + const setActiveVersion = useAppStore((s) => s.setActiveVersion) const flushSave = useAppStore((s) => s.flushSave) const handleExport = async (format: string, _label: string) => { @@ -79,8 +81,15 @@ export function ExportMenu() { if (hasPlaceholders) { source = activeLatex.replace(/\{\{(\w+)\}\}/g, (_, name) => parameterValues[name] ?? `{{${name}}}`) // Persiste primero; solo refleja en la UI si el servidor lo aceptó. - const result = await replacePageLatex(activePageId, source) + const version = useAppStore.getState().activeVersion + const result = await replacePageLatex( + activePageId, + source, + typeof version === 'number' ? version : undefined, + ) setActiveLatex(result.latex) + setActiveAst(result.ast) + setActiveVersion(result.version) } const blob = await exportPage(activePageId, format) @@ -113,7 +122,8 @@ export function ExportMenu() { handleExport(f.id, f.label)} - disabled={!activePageId} + // Sitio estático exporta todas las páginas; no requiere página activa. + disabled={f.id !== 'site' && !activePageId} className="gap-2" > diff --git a/ui/src/components/shell/settings-dialog.tsx b/ui/src/components/shell/settings-dialog.tsx index 484989c..703e6bc 100644 --- a/ui/src/components/shell/settings-dialog.tsx +++ b/ui/src/components/shell/settings-dialog.tsx @@ -289,7 +289,20 @@ export function SettingsDialog() { setLabMode(e.target.checked)} + onChange={async (e) => { + const next = e.target.checked + if (!next) { + const flushLab = useAppStore.getState().flushLabCells + if (flushLab) { + try { + await flushLab() + } catch { + /* best-effort */ + } + } + } + setLabMode(next) + }} className="size-3.5 accent-primary" /> Modo laboratorio (layout denso) diff --git a/ui/src/components/shell/templates-menu.tsx b/ui/src/components/shell/templates-menu.tsx index 0bbf45b..5ce52ae 100644 --- a/ui/src/components/shell/templates-menu.tsx +++ b/ui/src/components/shell/templates-menu.tsx @@ -47,7 +47,12 @@ export function TemplatesMenu() { } try { if (flushSave) await flushSave() - const result = await replacePageLatex(activePageId, content) + const version = useAppStore.getState().activeVersion + const result = await replacePageLatex( + activePageId, + content, + typeof version === 'number' ? version : undefined, + ) setActiveLatex(result.latex) setActiveAst(result.ast) setActiveVersion(result.version) diff --git a/ui/src/components/shell/toolbar.tsx b/ui/src/components/shell/toolbar.tsx index 104de5b..d6bbb91 100644 --- a/ui/src/components/shell/toolbar.tsx +++ b/ui/src/components/shell/toolbar.tsx @@ -125,12 +125,24 @@ export function Toolbar({ onCreatePage }: ToolbarProps) { variant={labMode ? 'secondary' : 'ghost'} size="sm" onClick={async () => { - // Persiste el buffer del editor antes de desmontarlo (modo lab). - if (!labMode && flushSave) { - try { - await flushSave() - } catch { - /* el backend puede fallar; el switch sigue */ + if (!labMode) { + // Persiste el buffer del editor antes de desmontarlo (modo lab). + if (flushSave) { + try { + await flushSave() + } catch { + /* el backend puede fallar; el switch sigue */ + } + } + } else { + // Al salir: celdas dirty del lab + resync de versión. + const flushLab = useAppStore.getState().flushLabCells + if (flushLab) { + try { + await flushLab() + } catch { + /* best-effort */ + } } } setLabMode(!labMode) diff --git a/ui/src/hooks/use-global-shortcuts.ts b/ui/src/hooks/use-global-shortcuts.ts index ba92e65..0ba7c94 100644 --- a/ui/src/hooks/use-global-shortcuts.ts +++ b/ui/src/hooks/use-global-shortcuts.ts @@ -75,7 +75,32 @@ export function useGlobalShortcuts() { if (run('toggleDiagrams', () => togglePanel('diagrams'))) return if (run('toggleParameters', () => togglePanel('parameters'))) return if (run('toggleCells', () => togglePanel('cells'))) return - if (run('toggleLabMode', () => setLabMode(!labMode))) return + if ( + run('toggleLabMode', async () => { + if (labMode) { + const flushLab = useAppStore.getState().flushLabCells + if (flushLab) { + try { + await flushLab() + } catch { + /* best-effort */ + } + } + setLabMode(false) + } else { + if (flushSave) { + try { + await flushSave() + } catch { + /* best-effort */ + } + } + setLabMode(true) + } + }) + ) { + return + } if ( run('newPage', async () => { diff --git a/ui/src/hooks/use-page-update.ts b/ui/src/hooks/use-page-update.ts index ea119ae..27f568a 100644 --- a/ui/src/hooks/use-page-update.ts +++ b/ui/src/hooks/use-page-update.ts @@ -13,6 +13,16 @@ interface UsePageUpdateResult { const DEBOUNCE_MS = 300 +function conflictCurrent(err: unknown): number | null { + if (!(err instanceof ApiError) || err.status !== 409) return null + const d = err.detail + if (d && typeof d === 'object' && d !== null && 'current' in d) { + const cur = (d as { current?: unknown }).current + if (typeof cur === 'number' && Number.isFinite(cur)) return cur + } + return null +} + export function usePageUpdate( pageId: string | null, onUpdate?: (page: Page) => void, @@ -38,8 +48,15 @@ export function usePageUpdate( if (raw != null && previousPageId) { const version = getVersion?.() void updatePageRaw(previousPageId, raw, version).catch((err) => { - // Página eliminada / 409: no reintentar en unmount. - if (err instanceof ApiError && (err.status === 404 || err.status === 409)) return + // Página eliminada: no reintentar en unmount. + if (err instanceof ApiError && err.status === 404) return + // 409: un reintento con la versión del servidor. + const cur = conflictCurrent(err) + if (cur != null) { + void updatePageRaw(previousPageId, raw, cur).catch(() => { + /* best-effort unmount */ + }) + } }) } } @@ -48,29 +65,51 @@ export function usePageUpdate( const save = useCallback( async (raw: string): Promise => { if (!pageId) return + + // Serializa: espera al PUT anterior para no mandar dos veces la misma versión. + const prev = inflightRef.current + if (prev) { + try { + await prev + } catch { + /* el error ya se reportó */ + } + } + const gen = genRef.current setStatus('saving') + // Versión fresca tras await del inflight (onUpdate pudo actualizar el store). const version = getVersion?.() + const work = (async (): Promise => { try { - const page = await updatePageRaw(pageId, raw, version) + let page: Page + try { + page = await updatePageRaw(pageId, raw, version) + } catch (err) { + const cur = conflictCurrent(err) + if (cur == null) throw err + // Un reintento con la versión actual del servidor. + page = await updatePageRaw(pageId, raw, cur) + } + // Siempre refresca versión/AST aunque haya otro draft pendiente. + onUpdate?.(page) if (gen !== genRef.current || pendingRef.current !== null) { + setStatus('saving') return page } setStatus('saved') - onUpdate?.(page) return page } catch (err) { if (gen === genRef.current) { setStatus('error') - // Re-encola el draft si el servidor falló (salvo conflicto de versión). - if (!(err instanceof ApiError && err.status === 409)) { - pendingRef.current = raw - } + // Conserva el draft (incl. 409 sin reintento viable) para no perder texto. + pendingRef.current = raw } throw err } })() + inflightRef.current = work try { return await work @@ -119,14 +158,19 @@ export function usePageUpdate( clearTimeout(timerRef.current) timerRef.current = null } + // Espera inflight antes de decidir si hay draft pendiente. + if (inflightRef.current) { + try { + await inflightRef.current + } catch { + /* continue with pending draft */ + } + } const raw = pendingRef.current pendingRef.current = null if (raw != null) { return save(raw) } - if (inflightRef.current) { - return inflightRef.current - } }, [save]) return { status, updateRaw, flush, discardPending } diff --git a/ui/src/lib/api-fetch.test.ts b/ui/src/lib/api-fetch.test.ts index e08b9c5..4e715c7 100644 --- a/ui/src/lib/api-fetch.test.ts +++ b/ui/src/lib/api-fetch.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { deleteCell, deletePage } from './api' +import { deleteCell, deletePage, getPage } from './api' describe('fetchJson empty-body responses', () => { afterEach(() => { @@ -22,4 +22,30 @@ describe('fetchJson empty-body responses', () => { ).resolves.toBeUndefined() expect(fetchMock).toHaveBeenCalled() }) + + it('detailToPage preserves project_id and updated_at from wire', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + page_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + title: 'Optics', + project_id: 'lab-1', + latex: 'x', + raw: 'x', + ast: [], + version: 3, + updated_at: '2026-01-15T12:00:00Z', + }), + headers: { get: () => 'application/json' }, + }) + vi.stubGlobal('fetch', fetchMock) + + const page = await getPage('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + expect(page.project_id).toBe('lab-1') + expect(page.updated_at).toBe('2026-01-15T12:00:00Z') + expect(page.title).toBe('Optics') + expect(page.version).toBe(3) + }) }) diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 9f0e303..95a31e5 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -61,22 +61,24 @@ async function fetchJson(input: string, init?: RequestInit): Promise { interface PageDetailWire { page_id: string title: string + project_id?: string | null latex: string raw: string ast: AstNode[] version: number + updated_at?: string | null } function detailToPage(d: PageDetailWire): Page { return { id: d.page_id, title: d.title, - project_id: null, + project_id: d.project_id ?? null, latex: d.latex, raw: d.raw, ast: d.ast, version: d.version, - updated_at: new Date().toISOString(), + updated_at: d.updated_at ?? new Date().toISOString(), } } @@ -181,18 +183,30 @@ export async function listCells(pageId: string): Promise { return fetchJson(`/pages/${pageId}/cells`) } -export async function insertCell(pageId: string, cell: Omit): Promise { - await fetchJson(`/pages/${pageId}/cells`, { +export async function insertCell( + pageId: string, + cell: Omit, +): Promise<{ version: number }> { + const res = await fetchJson<{ status: string; version?: number }>(`/pages/${pageId}/cells`, { method: 'POST', body: JSON.stringify(cell), }) + return { version: res?.version ?? 0 } } -export async function updateCell(pageId: string, cellId: string, cell: { language: string; source: string }): Promise { - await fetchJson(`/pages/${pageId}/cells/${cellId}/update`, { - method: 'POST', - body: JSON.stringify(cell), - }) +export async function updateCell( + pageId: string, + cellId: string, + cell: { language: string; source: string }, +): Promise<{ version: number }> { + const res = await fetchJson<{ status: string; version?: number }>( + `/pages/${pageId}/cells/${cellId}/update`, + { + method: 'POST', + body: JSON.stringify(cell), + }, + ) + return { version: res?.version ?? 0 } } export async function executeCell( @@ -214,11 +228,19 @@ export async function deleteCell(pageId: string, cellId: string): Promise await fetchJson(`/pages/${pageId}/cells/${cellId}`, { method: 'DELETE' }) } -export async function moveCell(pageId: string, cellId: string, newIndex: number): Promise { - await fetchJson(`/pages/${pageId}/cells/${cellId}/move`, { - method: 'POST', - body: JSON.stringify({ new_index: newIndex }), - }) +export async function moveCell( + pageId: string, + cellId: string, + newIndex: number, +): Promise<{ version: number }> { + const res = await fetchJson<{ status: string; version?: number }>( + `/pages/${pageId}/cells/${cellId}/move`, + { + method: 'POST', + body: JSON.stringify({ new_index: newIndex }), + }, + ) + return { version: res?.version ?? 0 } } export async function exportPages(): Promise<{ path: string }> { diff --git a/ui/src/stores/app-store.ts b/ui/src/stores/app-store.ts index 564004a..0b2e27e 100644 --- a/ui/src/stores/app-store.ts +++ b/ui/src/stores/app-store.ts @@ -131,6 +131,11 @@ interface AppState { flushSave: (() => Promise) | null /** Cancela autosave pendiente sin escribir (p.ej. tras congelar parámetros). */ discardPendingSave: (() => void) | null + /** + * Registrado por LabCanvas: persiste celdas dirty y re-sincroniza + * latex/versión antes de salir del modo laboratorio. + */ + flushLabCells: (() => Promise) | null /** Registrado por el editor: mueve el cursor a la línea indicada (1-based). */ goToLine: ((line: number) => void) | null /** Línea resaltada en el gutter (error PDF, etc.). */ @@ -171,6 +176,7 @@ interface AppState { setInsertAtCursor: (fn: ((text: string) => void) | null) => void setFlushSave: (fn: (() => Promise) | null) => void setDiscardPendingSave: (fn: (() => void) | null) => void + setFlushLabCells: (fn: (() => Promise) | null) => void setGoToLine: (fn: ((line: number) => void) | null) => void setHighlightLine: (line: number | null) => void } @@ -223,6 +229,7 @@ export const useAppStore = create((set, get) => ({ insertAtCursor: null, flushSave: null, discardPendingSave: null, + flushLabCells: null, goToLine: null, highlightLine: null, setPages: (pages) => set({ pages }), @@ -233,12 +240,14 @@ export const useAppStore = create((set, get) => ({ setSearchQuery: (searchQuery) => set({ searchQuery }), togglePanel: (id) => set((state) => { + if (!(id in state.panels)) return state const panels = { ...state.panels, [id]: !state.panels[id] } persist('lablog-panels', JSON.stringify(panels)) return { panels } }), setPanel: (id, open) => set((state) => { + if (!(id in state.panels)) return state const panels = { ...state.panels, [id]: open } persist('lablog-panels', JSON.stringify(panels)) return { panels } @@ -251,7 +260,11 @@ export const useAppStore = create((set, get) => ({ setAccent: (accent) => set({ accent }), setPalette: (palette) => set({ palette }), setCustomColors: (customColors) => set({ customColors }), - setFontScale: (fontScale) => set({ fontScale }), + setFontScale: (fontScale) => { + const n = Number(fontScale) + if (!Number.isFinite(n)) return + set({ fontScale: Math.min(150, Math.max(70, Math.round(n))) }) + }, setDensity: (density) => { persist('lablog-density', density) set({ density }) @@ -296,7 +309,9 @@ export const useAppStore = create((set, get) => ({ if (prefs.accent != null) next.accent = prefs.accent if (prefs.palette != null) next.palette = prefs.palette if (prefs.customColors != null) next.customColors = prefs.customColors - if (prefs.fontScale != null) next.fontScale = prefs.fontScale + if (prefs.fontScale != null && Number.isFinite(Number(prefs.fontScale))) { + next.fontScale = Math.min(150, Math.max(70, Math.round(Number(prefs.fontScale)))) + } if (prefs.density === 'comfortable' || prefs.density === 'compact') { next.density = prefs.density } @@ -348,6 +363,7 @@ export const useAppStore = create((set, get) => ({ setInsertAtCursor: (insertAtCursor) => set({ insertAtCursor }), setFlushSave: (flushSave) => set({ flushSave }), setDiscardPendingSave: (discardPendingSave) => set({ discardPendingSave }), + setFlushLabCells: (flushLabCells) => set({ flushLabCells }), setGoToLine: (goToLine) => set({ goToLine }), setHighlightLine: (highlightLine) => set({ highlightLine }), }))