boxframe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
boxframe/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """boxframe — Pseudo-graphic UI mockup editor for AI agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("boxframe")
7
+ except PackageNotFoundError: # installed without metadata (e.g. run from source)
8
+ __version__ = "0.0.0"
@@ -0,0 +1,4 @@
1
+ from boxframe.api.projects import router as projects_router
2
+ from boxframe.api.layouts import router as layouts_router
3
+
4
+ __all__ = ["projects_router", "layouts_router"]
@@ -0,0 +1,349 @@
1
+ """
2
+ Layout API routes.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
+ from pydantic import BaseModel, Field
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from boxframe.database import get_db
12
+ from boxframe.services.layout_service import LayoutService
13
+ from boxframe.services.renderer import PseudoGraphicRenderer
14
+
15
+ router = APIRouter(prefix="/api/layouts", tags=["layouts"])
16
+
17
+
18
+ # ── Pydantic schemas ──────────────────────────────────────
19
+
20
+ class BlockOut(BaseModel):
21
+ id: str
22
+ block_type: str
23
+ x: int
24
+ y: int
25
+ width: int
26
+ height: int
27
+ content: str
28
+ border_style: str
29
+ parent_id: str | None
30
+ meta: dict
31
+ order: int
32
+ created_at: datetime
33
+ updated_at: datetime
34
+
35
+ class Config:
36
+ from_attributes = True
37
+
38
+
39
+ class LayoutCreate(BaseModel):
40
+ name: str = Field(..., min_length=1, max_length=255)
41
+ # Optional: a layout may have a width, a height, both, or neither.
42
+ # No upper limit: a set dimension is a visual bounds line in the
43
+ # editor — blocks may be placed outside it, it is not a constraint.
44
+ width: int | None = Field(default=None, ge=1)
45
+ height: int | None = Field(default=None, ge=1)
46
+
47
+
48
+ class LayoutUpdate(BaseModel):
49
+ name: str | None = None
50
+ width: int | None = None
51
+ height: int | None = None
52
+
53
+
54
+ class LayoutOut(BaseModel):
55
+ id: str
56
+ project_id: str
57
+ name: str
58
+ width: int | None = None
59
+ height: int | None = None
60
+ blocks: list[BlockOut] = []
61
+ created_at: datetime
62
+ updated_at: datetime
63
+
64
+ class Config:
65
+ from_attributes = True
66
+
67
+
68
+ class BlockCreate(BaseModel):
69
+ block_type: str = Field(..., description="Type of block (box, button, input, etc.)")
70
+ x: int = 0
71
+ y: int = 0
72
+ width: int = 20
73
+ height: int = 3
74
+ content: str = ""
75
+ border_style: str = "solid"
76
+ parent_id: str | None = None
77
+ meta: dict = {}
78
+ order: int = 0
79
+
80
+
81
+ class BlockUpdate(BaseModel):
82
+ block_type: str | None = None
83
+ x: int | None = None
84
+ y: int | None = None
85
+ width: int | None = None
86
+ height: int | None = None
87
+ content: str | None = None
88
+ border_style: str | None = None
89
+ parent_id: str | None = None
90
+ meta: dict | None = None
91
+ order: int | None = None
92
+
93
+
94
+ class BlockIn(BaseModel):
95
+ """A block in a full-state replace payload.
96
+
97
+ id is optional: given ids are preserved (undo/redo restores the same
98
+ block identity), missing ids are generated server-side.
99
+ """
100
+
101
+ id: str | None = None
102
+ block_type: str = "box"
103
+ x: int = 0
104
+ y: int = 0
105
+ width: int = 20
106
+ height: int = 3
107
+ content: str = ""
108
+ border_style: str = "solid"
109
+ parent_id: str | None = None
110
+ meta: dict = {}
111
+ order: int = 0
112
+
113
+
114
+ class BlocksReplace(BaseModel):
115
+ blocks: list[BlockIn]
116
+
117
+
118
+ class RenderOut(BaseModel):
119
+ ascii: str
120
+ html: str | None = None
121
+
122
+
123
+ class ExportOut(BaseModel):
124
+ layout_json: dict | None = None
125
+ markdown: str | None = None
126
+ ascii: str | None = None
127
+
128
+
129
+ # ── Layout CRUD ───────────────────────────────────────────
130
+
131
+ @router.post("/{project_id}/layouts", response_model=LayoutOut)
132
+ async def create_layout(project_id: str, data: LayoutCreate, db: AsyncSession = Depends(get_db)):
133
+ service = LayoutService(db)
134
+ layout = await service.create_layout(project_id, data.name, data.width, data.height)
135
+ return layout
136
+
137
+
138
+ @router.get("/{layout_id}", response_model=LayoutOut)
139
+ async def get_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
140
+ service = LayoutService(db)
141
+ layout = await service.get_layout(layout_id)
142
+ if not layout:
143
+ raise HTTPException(status_code=404, detail="Layout not found")
144
+ return layout
145
+
146
+
147
+ @router.delete("/{layout_id}")
148
+ async def delete_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
149
+ service = LayoutService(db)
150
+ layout = await service.get_layout(layout_id)
151
+ if not layout:
152
+ raise HTTPException(status_code=404, detail="Layout not found")
153
+ await service.delete_layout(layout_id)
154
+ return {"ok": True}
155
+
156
+
157
+ # ── Block CRUD ────────────────────────────────────────────
158
+
159
+ @router.post("/{layout_id}/blocks", response_model=BlockOut)
160
+ async def create_block(layout_id: str, data: BlockCreate, db: AsyncSession = Depends(get_db)):
161
+ service = LayoutService(db)
162
+ block = await service.create_block(
163
+ layout_id=layout_id,
164
+ block_type=data.block_type,
165
+ x=data.x,
166
+ y=data.y,
167
+ width=data.width,
168
+ height=data.height,
169
+ content=data.content,
170
+ border_style=data.border_style,
171
+ parent_id=data.parent_id,
172
+ meta=data.meta,
173
+ order=data.order,
174
+ )
175
+ return block
176
+
177
+
178
+ @router.put("/{layout_id}/blocks/{block_id}", response_model=BlockOut)
179
+ async def update_block(layout_id: str, block_id: str, data: BlockUpdate, db: AsyncSession = Depends(get_db)):
180
+ service = LayoutService(db)
181
+ block = await service.get_block(block_id)
182
+ if not block or block.layout_id != layout_id:
183
+ raise HTTPException(status_code=404, detail="Block not found")
184
+
185
+ update_data = data.model_dump(exclude_none=True)
186
+ if not update_data:
187
+ return block
188
+
189
+ updated = await service.update_block(block_id, **update_data)
190
+ return updated
191
+
192
+
193
+ @router.delete("/{layout_id}/blocks/{block_id}")
194
+ async def delete_block(layout_id: str, block_id: str, db: AsyncSession = Depends(get_db)):
195
+ service = LayoutService(db)
196
+ block = await service.get_block(block_id)
197
+ if not block or block.layout_id != layout_id:
198
+ raise HTTPException(status_code=404, detail="Block not found")
199
+ await service.delete_block(block_id)
200
+ return {"ok": True}
201
+
202
+
203
+ @router.delete("/{layout_id}/blocks")
204
+ async def clear_layout_blocks(layout_id: str, db: AsyncSession = Depends(get_db)):
205
+ """Remove all blocks from a layout (the layout itself is kept)."""
206
+ service = LayoutService(db)
207
+ layout = await service.get_layout(layout_id)
208
+ if not layout:
209
+ raise HTTPException(status_code=404, detail="Layout not found")
210
+ deleted = await service.delete_all_blocks(layout_id)
211
+ return {"ok": True, "deleted": deleted}
212
+
213
+
214
+ @router.put("/{layout_id}/blocks", response_model=LayoutOut)
215
+ async def replace_layout_blocks(layout_id: str, data: BlocksReplace, db: AsyncSession = Depends(get_db)):
216
+ """Replace all blocks of a layout with the given set (full state restore).
217
+
218
+ Backs the editor's undo/redo: the client sends a state snapshot and the
219
+ layout is atomically restored to it. Block ids from the payload are kept.
220
+ """
221
+ service = LayoutService(db)
222
+ layout = await service.get_layout(layout_id)
223
+ if not layout:
224
+ raise HTTPException(status_code=404, detail="Layout not found")
225
+ updated = await service.replace_blocks(layout_id, [b.model_dump() for b in data.blocks])
226
+ return updated
227
+
228
+
229
+ # ── Rendering ─────────────────────────────────────────────
230
+
231
+ @router.get("/{layout_id}/render", response_model=RenderOut)
232
+ async def render_layout(
233
+ layout_id: str,
234
+ char_width_px: float = Query(default=12.0, ge=1, le=50),
235
+ char_height_px: float = Query(default=14.4, ge=1, le=50),
236
+ db: AsyncSession = Depends(get_db),
237
+ ):
238
+ """Render layout to pseudo-graphic ASCII art.
239
+
240
+ char_width_px / char_height_px — measured pixel dimensions of a single
241
+ character. JS measures the real .ascii-art character size and passes
242
+ them back so overlays match exactly.
243
+ """
244
+ service = LayoutService(db)
245
+ ascii_art = await service.render_layout(layout_id)
246
+ if ascii_art is None:
247
+ raise HTTPException(status_code=404, detail="Layout not found")
248
+
249
+ # Generate HTML preview with block overlays and resize handles
250
+ layout = await service.get_layout(layout_id)
251
+ html = ""
252
+ if layout:
253
+ # Canvas = layout size expanded to fit blocks outside the bounds
254
+ # (the ASCII grid is rendered the same way).
255
+ canvas_w, canvas_h = service.canvas_size(layout)
256
+ # Compute exact pixel dimensions so the art layer and overlays share
257
+ # the same size.
258
+ pre_width = canvas_w * char_width_px
259
+ pre_height = round(canvas_h * char_height_px, 1)
260
+ pad = 16.0
261
+ # Padding lives on .render-wrapper; the art layer has no margin so
262
+ # overlay coordinates (padding_offset + grid * char_size) align
263
+ # exactly. It is a <div>, not a <pre>: the HTML parser strips the
264
+ # first newline right after a <pre> start tag, which would drop the
265
+ # first row when the art starts with an empty row (topmost block
266
+ # not at y=0) and shift the art up one row relative to the overlays.
267
+ ascii_html = (
268
+ f'<div class="ascii-art" style="font-family: monospace; font-size: 12px; '
269
+ f'line-height: 1.2; display: block; white-space: pre; '
270
+ f'width:{pre_width}px; height:{pre_height}px; '
271
+ f'background: #1a1a2e; color: #e0e0e0;">'
272
+ f"{ascii_art.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')}</div>"
273
+ )
274
+ blocks_data = _serialize_blocks_for_html(layout.blocks)
275
+ block_previews_html = PseudoGraphicRenderer.render_html_preview(
276
+ blocks_data,
277
+ char_width_px=char_width_px,
278
+ char_height_px=char_height_px,
279
+ padding_offset=pad,
280
+ )
281
+ # Layout bounds overlay: a set width/height draws a visual line on
282
+ # the editor canvas. It does not affect block placement or the
283
+ # canvas size, and it is not part of the ASCII art.
284
+ bounds_html = PseudoGraphicRenderer.render_bounds_html(
285
+ layout.width,
286
+ layout.height,
287
+ char_width_px=char_width_px,
288
+ char_height_px=char_height_px,
289
+ padding_offset=pad,
290
+ )
291
+ html = (
292
+ f'<div class="render-wrapper" '
293
+ f'style="width:{pre_width}px; height:{pre_height}px;">'
294
+ f"{ascii_html}"
295
+ f"{bounds_html}"
296
+ f"{block_previews_html}"
297
+ f"</div>"
298
+ )
299
+
300
+ return RenderOut(ascii=ascii_art, html=html)
301
+
302
+
303
+ def _serialize_blocks_for_html(blocks: list) -> list[dict]:
304
+ """Serialize ORM blocks to dicts for HTML preview generation."""
305
+ by_id = {b.id: b for b in blocks}
306
+ result = []
307
+ for block in blocks:
308
+ if block.parent_id and block.parent_id in by_id:
309
+ continue
310
+ bd = {
311
+ "id": block.id,
312
+ "x": block.x,
313
+ "y": block.y,
314
+ "width": block.width,
315
+ "height": block.height,
316
+ "block_type": block.block_type,
317
+ "content": block.content,
318
+ "border_style": block.border_style,
319
+ "order": block.order,
320
+ "children": [
321
+ {
322
+ "id": c.id,
323
+ "x": c.x,
324
+ "y": c.y,
325
+ "width": c.width,
326
+ "height": c.height,
327
+ "block_type": c.block_type,
328
+ "content": c.content,
329
+ "border_style": c.border_style,
330
+ "order": c.order,
331
+ }
332
+ for c in blocks
333
+ if c.parent_id == block.id
334
+ ],
335
+ }
336
+ result.append(bd)
337
+ return result
338
+
339
+
340
+ @router.get("/{layout_id}/export", response_model=ExportOut)
341
+ async def export_layout(layout_id: str, db: AsyncSession = Depends(get_db)):
342
+ """Export layout in multiple formats."""
343
+ service = LayoutService(db)
344
+
345
+ json_data = await service.export_json(layout_id)
346
+ markdown = await service.export_markdown(layout_id)
347
+ ascii_art = await service.render_layout(layout_id)
348
+
349
+ return ExportOut(layout_json=json_data, markdown=markdown, ascii=ascii_art)
@@ -0,0 +1,143 @@
1
+ """
2
+ Project API routes.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+ from pydantic import BaseModel, Field
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from boxframe.database import get_db
12
+ from boxframe.models.block import BLOCK_TYPES, BORDER_STYLES
13
+ from boxframe.services.layout_service import LayoutService
14
+
15
+ router = APIRouter(prefix="/api/projects", tags=["projects"])
16
+
17
+
18
+ # ── Pydantic schemas ──────────────────────────────────────
19
+
20
+ class ProjectCreate(BaseModel):
21
+ name: str = Field(..., min_length=1, max_length=255)
22
+
23
+
24
+ class ProjectOut(BaseModel):
25
+ id: str
26
+ name: str
27
+ created_at: datetime
28
+ updated_at: datetime
29
+
30
+ class Config:
31
+ from_attributes = True
32
+
33
+
34
+ class LayoutOut(BaseModel):
35
+ id: str
36
+ name: str
37
+ width: int | None = None
38
+ height: int | None = None
39
+ created_at: datetime
40
+ updated_at: datetime
41
+
42
+ class Config:
43
+ from_attributes = True
44
+
45
+
46
+ class BlockSchema(BaseModel):
47
+ id: str | None = None
48
+ block_type: str
49
+ x: int = 0
50
+ y: int = 0
51
+ width: int = 20
52
+ height: int = 3
53
+ content: str = ""
54
+ border_style: str = "solid"
55
+ parent_id: str | None = None
56
+ meta: dict = {}
57
+ order: int = 0
58
+
59
+
60
+ class BlockOut(BaseModel):
61
+ id: str
62
+ block_type: str
63
+ x: int
64
+ y: int
65
+ width: int
66
+ height: int
67
+ content: str
68
+ border_style: str
69
+ parent_id: str | None
70
+ meta: dict
71
+ order: int
72
+ created_at: datetime
73
+ updated_at: datetime
74
+
75
+ class Config:
76
+ from_attributes = True
77
+
78
+
79
+ # ── Routes ────────────────────────────────────────────────
80
+
81
+ @router.get("/", response_model=list[ProjectOut])
82
+ async def list_projects(db: AsyncSession = Depends(get_db)):
83
+ service = LayoutService(db)
84
+ projects = await service.list_projects()
85
+ return projects
86
+
87
+
88
+ @router.post("/", response_model=ProjectOut)
89
+ async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
90
+ service = LayoutService(db)
91
+ project = await service.create_project(data.name)
92
+ return project
93
+
94
+
95
+ @router.get("/{project_id}", response_model=ProjectOut)
96
+ async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
97
+ service = LayoutService(db)
98
+ project = await service.get_project(project_id)
99
+ if not project:
100
+ raise HTTPException(status_code=404, detail="Project not found")
101
+ return project
102
+
103
+
104
+ @router.delete("/{project_id}")
105
+ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
106
+ service = LayoutService(db)
107
+ project = await service.get_project(project_id)
108
+ if not project:
109
+ raise HTTPException(status_code=404, detail="Project not found")
110
+ await service.delete_project(project_id)
111
+ return {"ok": True}
112
+
113
+
114
+ @router.get("/{project_id}/info", response_model=dict)
115
+ async def project_info(project_id: str, db: AsyncSession = Depends(get_db)):
116
+ """Return project metadata including available block types and border styles."""
117
+ service = LayoutService(db)
118
+ project = await service.get_project(project_id)
119
+ if not project:
120
+ raise HTTPException(status_code=404, detail="Project not found")
121
+
122
+ return {
123
+ "project": {"id": project.id, "name": project.name},
124
+ "layouts": [{"id": l.id, "name": l.name} for l in project.layouts],
125
+ "block_types": BLOCK_TYPES,
126
+ "border_styles": BORDER_STYLES,
127
+ }
128
+
129
+
130
+ class LayoutCreate(BaseModel):
131
+ name: str = Field(..., min_length=1, max_length=255)
132
+ # Optional: a layout may have a width, a height, both, or neither.
133
+ # A set dimension is a visual bounds line in the editor, not a constraint.
134
+ width: int | None = Field(default=None, ge=1)
135
+ height: int | None = Field(default=None, ge=1)
136
+
137
+
138
+ @router.post("/{project_id}/layouts", response_model=LayoutOut)
139
+ async def create_layout(project_id: str, data: LayoutCreate, db: AsyncSession = Depends(get_db)):
140
+ """Create a new layout for a project."""
141
+ service = LayoutService(db)
142
+ layout = await service.create_layout(project_id, data.name, data.width, data.height)
143
+ return layout
@@ -0,0 +1,62 @@
1
+ """
2
+ Test cleanup API route.
3
+
4
+ Deletes all projects, layouts, and blocks whose names start with
5
+ the ``E2E: `` prefix. This keeps the production database clean
6
+ when running E2E tests against a shared server.
7
+ """
8
+
9
+ from fastapi import APIRouter, Depends
10
+ from sqlalchemy import delete, select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from boxframe.database import get_db
14
+ from boxframe.models.block import Block
15
+ from boxframe.models.layout import Layout
16
+ from boxframe.models.project import Project
17
+
18
+ router = APIRouter(prefix="/api/test", tags=["test"])
19
+
20
+ E2E_PREFIX = "E2E: "
21
+
22
+
23
+ @router.delete("/cleanup")
24
+ async def cleanup_e2e_data(db: AsyncSession = Depends(get_db)):
25
+ """Delete all E2E test data (projects, layouts, blocks with E2E prefix)."""
26
+ # 1. Collect layout IDs belonging to E2E projects
27
+ e2e_projects = await db.execute(
28
+ select(Project.id).where(Project.name.like(f"{E2E_PREFIX}%"))
29
+ )
30
+ e2e_project_ids = set(e2e_projects.scalars().all())
31
+
32
+ # 2. Collect layout IDs for those projects
33
+ e2e_layouts = await db.execute(
34
+ select(Layout.id).where(Layout.project_id.in_(e2e_project_ids))
35
+ )
36
+ e2e_layout_ids = set(e2e_layouts.scalars().all())
37
+
38
+ # 3. Delete blocks belonging to those layouts
39
+ deleted_blocks = 0
40
+ if e2e_layout_ids:
41
+ result = await db.execute(delete(Block).where(Block.layout_id.in_(e2e_layout_ids)))
42
+ deleted_blocks = result.rowcount
43
+
44
+ # 4. Delete layouts
45
+ deleted_layouts = 0
46
+ if e2e_layout_ids:
47
+ result = await db.execute(delete(Layout).where(Layout.id.in_(e2e_layout_ids)))
48
+ deleted_layouts = result.rowcount
49
+
50
+ # 5. Delete projects
51
+ deleted_projects = 0
52
+ if e2e_project_ids:
53
+ result = await db.execute(delete(Project).where(Project.id.in_(e2e_project_ids)))
54
+ deleted_projects = result.rowcount
55
+
56
+ await db.commit()
57
+
58
+ return {
59
+ "deleted_projects": deleted_projects,
60
+ "deleted_layouts": deleted_layouts,
61
+ "deleted_blocks": deleted_blocks,
62
+ }
boxframe/config.py ADDED
@@ -0,0 +1,14 @@
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ app_name: str = "boxframe"
6
+ database_url: str = "sqlite+aiosqlite:///./boxframe.db"
7
+ debug: bool = False
8
+ log_dir: str = "log"
9
+
10
+ class Config:
11
+ env_prefix = "BOXFRAME_"
12
+
13
+
14
+ settings = Settings()
boxframe/database.py ADDED
@@ -0,0 +1,62 @@
1
+ from collections.abc import AsyncGenerator
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
4
+ from sqlalchemy.orm import DeclarativeBase
5
+
6
+ from boxframe.config import settings
7
+
8
+ engine = create_async_engine(settings.database_url, echo=settings.debug)
9
+ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
10
+
11
+
12
+ class Base(DeclarativeBase):
13
+ pass
14
+
15
+
16
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
17
+ async with async_session() as session:
18
+ try:
19
+ yield session
20
+ finally:
21
+ await session.close()
22
+
23
+
24
+ async def init_db():
25
+ async with engine.begin() as conn:
26
+ await conn.run_sync(_migrate_layouts_size_nullable)
27
+ await conn.run_sync(Base.metadata.create_all)
28
+
29
+
30
+ def _migrate_layouts_size_nullable(conn) -> None:
31
+ """One-off migration: layouts.width/height became nullable.
32
+
33
+ SQLite cannot drop NOT NULL in place, so the table is rebuilt when the
34
+ old (non-nullable) schema is detected. Existing data is preserved.
35
+ """
36
+ result = conn.exec_driver_sql("PRAGMA table_info(layouts)")
37
+ cols = {row[1]: row for row in result.fetchall()}
38
+ if not cols:
39
+ return # table does not exist yet — create_all will make it
40
+ # PRAGMA table_info row: (cid, name, type, notnull, dflt_value, pk)
41
+ if not cols["width"][3] and not cols["height"][3]:
42
+ return # already nullable
43
+ conn.exec_driver_sql(
44
+ """
45
+ CREATE TABLE layouts_migrated (
46
+ id VARCHAR(36) PRIMARY KEY,
47
+ project_id VARCHAR(36) NOT NULL REFERENCES projects(id),
48
+ name VARCHAR(255) NOT NULL,
49
+ width INTEGER,
50
+ height INTEGER,
51
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
52
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
53
+ )
54
+ """
55
+ )
56
+ conn.exec_driver_sql(
57
+ "INSERT INTO layouts_migrated "
58
+ "(id, project_id, name, width, height, created_at, updated_at) "
59
+ "SELECT id, project_id, name, width, height, created_at, updated_at FROM layouts"
60
+ )
61
+ conn.exec_driver_sql("DROP TABLE layouts")
62
+ conn.exec_driver_sql("ALTER TABLE layouts_migrated RENAME TO layouts")