Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
959 changes: 959 additions & 0 deletions README.es.md

Large diffs are not rendered by default.

983 changes: 708 additions & 275 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/assets/architecture-layers.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hero-academic.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/01-workbench.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/02-diagrams-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/03-parameters-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/04-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/05-shortcuts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/07-lab-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/screenshots/08-cells-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/ui-workbench.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
184 changes: 184 additions & 0 deletions scripts/capture_ui_screenshots.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
66 changes: 51 additions & 15 deletions src/lablog/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 11 additions & 2 deletions src/lablog/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/lablog/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/lablog/diagrams/expand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading