"""FlowDeck — Collections router: Notion-style databases (v1.3.0).""" from __future__ import annotations import json import logging import sqlite3 from fastapi import APIRouter, Request, HTTPException, Query from fastapi.responses import HTMLResponse from app.db import get_conn from app.auth.session import SessionManager from app.services.property_types import validate_property_rule from app.services.db_templates import materialize_properties logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") def _apply_template(conn, template_name: str) -> dict | None: """Resolve a database template by name (from the seeded/built-in set).""" if not template_name: return None row = conn.execute( "SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?", (template_name,), ).fetchone() if row: return dict(row) return None def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None: """Validate submitted property values against the collection's schema. Raises ``HTTPException(400)`` with a user-friendly message on the first failure (type, required, unique, min/max). """ props = conn.execute( "SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,) ).fetchall() for prop in props: ptype = prop["prop_type"] if ptype == "title": continue pid = prop["id"] # Values may be keyed by property id (FlowDeckDB UI) or by name (agent). value = properties.get(str(pid)) if value is None: value = properties.get(prop["name"]) validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}" existing_values = None try: import json as _json vcfg = _json.loads(validation) if validation else {} except Exception: vcfg = {} if vcfg.get("unique"): rows = conn.execute( "SELECT id, property_values_json FROM collection_pages WHERE collection_id=?", (collection_id,), ).fetchall() existing_values = [] for r in rows: if exclude_page_id is not None and r["id"] == exclude_page_id: continue try: pv = _json.loads(r["property_values_json"] or "{}") except Exception: pv = {} existing_values.append(pv.get(str(pid)) or pv.get(prop["name"])) ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values) if not ok: raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}") # ── API: List & Create (no path params) ── @router.get("", response_class=HTMLResponse) async def list_collections(request: Request): """Page listing all collections in the workspace.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collections ORDER BY name" ).fetchall() collections = [dict(r) for r in rows] return HTMLResponse( f"
" f"

Collections ({len(collections)})

" f"
{json.dumps(collections, indent=2, default=str)}
" f"
" ) @router.get("/api") async def list_collections_api(request: Request): """API: list all collections.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collections ORDER BY name" ).fetchall() return {"collections": [dict(r) for r in rows]} @router.post("/api") async def create_collection_api(request: Request): """API: create a new collection, optionally from a database template.""" try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") description = body.get("description", "") icon = body.get("icon", "📋") gitea_owner = body.get("gitea_owner") gitea_repo = body.get("gitea_repo") schema = body.get("schema", []) is_locked = body.get("is_locked", False) with get_conn() as conn: # Apply a template if requested (provides schema + icon). tpl = _apply_template(conn, body.get("template")) if tpl: if body.get("name"): name = body["name"].strip() description = tpl["description"] icon = tpl.get("icon") or icon try: schema = json.loads(tpl["schema_json"]) except (json.JSONDecodeError, TypeError): schema = [] schema_json = json.dumps(schema) cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked) VALUES (?, ?, ?, ?, ?, ?, ?)""", (name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)), ) collection_id = cur.lastrowid materialize_properties(conn, collection_id, schema) conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)""", (collection_id, "Default View", "table", json.dumps({ "visible_properties": ["Title"], "sorts": [], "filters": [], })), ) conn.commit() return {"id": collection_id, "name": name, "status": "created"} # ── API: Update & Delete (no path-param conflicts) ── @router.put("/api/{collection_id}") async def update_collection_api(request: Request, collection_id: int): """API: update a collection.""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Collection not found") name = body.get("name", existing["name"]) description = body.get("description", existing["description"]) icon = body.get("icon", existing["icon"]) is_locked = body.get("is_locked", existing["is_locked"]) schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"]))) gitea_owner = body.get("gitea_owner", existing["gitea_owner"]) gitea_repo = body.get("gitea_repo", existing["gitea_repo"]) conn.execute( """UPDATE collections SET name=?, description=?, icon=?, schema_json=?, is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP WHERE id=?""", (name, description, icon, schema_json, int(is_locked), gitea_owner, gitea_repo, collection_id), ) conn.commit() return {"id": collection_id, "status": "updated"} @router.delete("/api/{collection_id}") async def delete_collection_api(request: Request, collection_id: int): """API: delete a collection and its pages (CASCADE).""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Collection not found") conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) conn.commit() return {"id": collection_id, "status": "deleted"} # ── Page CRUD (standalone, BEFORE collection wildcards) ── @router.get("/pages/{page_id}/api") async def get_page_api(request: Request, page_id: int): """API: get a single page.""" with get_conn() as conn: page = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") return dict(page) @router.put("/pages/{page_id}/api") async def update_page_api(request: Request, page_id: int): """API: update a page's properties.""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Page not found") title = body.get("title", existing["title"]) icon = body.get("icon", existing["icon"]) position = body.get("position", existing["position"]) parent_id = body.get("parent_id", existing["parent_id"]) if "properties" in body: props = body["properties"] else: try: props = json.loads(existing["property_values_json"]) except (json.JSONDecodeError, TypeError): props = {} _validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id) property_values = json.dumps(props) conn.execute( """UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?""", (title, icon, position, parent_id, property_values, page_id), ) conn.commit() return {"id": page_id, "status": "updated"} @router.delete("/pages/{page_id}/api") async def delete_page_api(request: Request, page_id: int): """API: delete a page from its collection.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Page not found") conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() return {"id": page_id, "status": "deleted"} # ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ── @router.get("/boards/api") async def list_boards_as_collections(request: Request): """API: list all Gitea boards as pseudo-collections.""" from app.services.collection_adapter import GiteaBoardCompat boards = GiteaBoardCompat.list_boards_as_collections() return {"boards": boards} @router.get("/board/{owner}/{repo}/api") async def get_board_as_collection(request: Request, owner: str, repo: str): """API: get a specific Gitea board as a pseudo-collection.""" from app.services.collection_adapter import GiteaBoardCompat coll = GiteaBoardCompat.get_board_as_collection(owner, repo) if not coll: raise HTTPException(status_code=404, detail="Board not found") from app.services.gitea_client import gitea issues = await gitea.get_issues(owner, repo, state="all") cards = GiteaBoardCompat.get_board_cards(owner, repo, issues) return {"collection": coll, "pages": cards} @router.post("/board/{owner}/{repo}/sync") async def sync_board_to_collection(request: Request, owner: str, repo: str): """Sync a Gitea board to a real collection.""" from app.services.collection_adapter import GiteaBoardCompat from app.services.gitea_client import gitea issues = await gitea.get_issues(owner, repo, state="all") coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues) if coll_id is None: raise HTTPException(status_code=404, detail="Board not found") return {"collection_id": coll_id, "status": "synced"} # ── Collection Properties (v1.4.0) ── @router.get("/property-types/api") async def list_property_types_api(request: Request): """API: list all available property types.""" from app.services.property_types import PROPERTY_TYPES return {"types": PROPERTY_TYPES} @router.get("/{collection_id}/properties/api") async def list_properties_api(request: Request, collection_id: int): """API: list all properties for a collection.""" with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") rows = conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return {"properties": [dict(r) for r in rows]} @router.post("/{collection_id}/properties/api") async def create_property_api(request: Request, collection_id: int): """API: create a new property on a collection.""" try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") prop_type = body.get("prop_type", "text") options_json = json.dumps(body.get("options", [])) number_format = body.get("number_format", "number") required = int(body.get("required", False)) visible = int(body.get("visible_in_views", True)) validation_json = json.dumps(body.get("validation", {})) with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (collection_id,), ).fetchone()[0] try: cur = conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (collection_id, name, prop_type, options_json, number_format, max_pos, required, visible, validation_json), ) conn.commit() except Exception: raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"} @router.put("/properties/{prop_id}/api") async def update_property_api(request: Request, prop_id: int): """API: update a property.""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_properties WHERE id=?", (prop_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Property not found") name = body.get("name", existing["name"]) options_json = json.dumps(body.get("options", json.loads(existing["options_json"]))) number_format = body.get("number_format", existing["number_format"]) required = int(body.get("required", existing["required"])) visible = int(body.get("visible_in_views", existing["visible_in_views"])) if "validation" in body: validation_json = json.dumps(body.get("validation", {})) else: validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}" conn.execute( """UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=? WHERE id=?""", (name, options_json, number_format, required, visible, validation_json, prop_id), ) conn.commit() return {"id": prop_id, "status": "updated"} @router.delete("/properties/{prop_id}/api") async def delete_property_api(request: Request, prop_id: int): """API: delete a property.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_properties WHERE id=?", (prop_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Property not found") conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,)) conn.commit() return {"id": prop_id, "status": "deleted"} # ── Relations, Rollups, Formulas (v1.5.0) ── @router.post("/{collection_id}/properties/relation") async def create_relation_property(request: Request, collection_id: int): """Create a relation property between two collections.""" try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() related_collection_id = body.get("related_collection_id") reverse_name = body.get("reverse_name", "").strip() if not name or not related_collection_id: raise HTTPException(status_code=400, detail="name and related_collection_id are required") with get_conn() as conn: # Verify both collections exist for cid in (collection_id, related_collection_id): if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone(): raise HTTPException(status_code=404, detail=f"Collection {cid} not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (collection_id,), ).fetchone()[0] cur = conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)""", (collection_id, name, related_collection_id, reverse_name, max_pos), ) prop_id = cur.lastrowid # Create reverse relation on the related collection if reverse_name: max_pos2 = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (related_collection_id,), ).fetchone()[0] conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)""", (related_collection_id, reverse_name, collection_id, name, max_pos2), ) conn.commit() return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"} @router.post("/{collection_id}/properties/relation/link") async def link_pages(request: Request, collection_id: int): """Link two pages via a relation property.""" try: body = await request.json() except Exception: body = {} property_id = body.get("property_id") source_page_id = body.get("source_page_id") target_page_id = body.get("target_page_id") if not all([property_id, source_page_id, target_page_id]): raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required") with get_conn() as conn: # Get the relation property prop = conn.execute( "SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'", (property_id,), ).fetchone() if not prop: raise HTTPException(status_code=404, detail="Relation property not found") # Update source page's property_values_json source = conn.execute( "SELECT property_values_json FROM collection_pages WHERE id=?", (source_page_id,), ).fetchone() if not source: raise HTTPException(status_code=404, detail="Source page not found") props = json.loads(source["property_values_json"]) current = props.get(str(property_id), []) if not isinstance(current, list): current = [] if target_page_id not in current: current.append(target_page_id) props[str(property_id)] = current conn.execute( "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(props), source_page_id), ) # Update reverse relation if exists if prop["reverse_name"]: reverse_prop = conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'", (prop["related_collection_id"], prop["reverse_name"]), ).fetchone() if reverse_prop: target = conn.execute( "SELECT property_values_json FROM collection_pages WHERE id=?", (target_page_id,), ).fetchone() if target: tprops = json.loads(target["property_values_json"]) tcurrent = tprops.get(str(reverse_prop["id"]), []) if not isinstance(tcurrent, list): tcurrent = [] if source_page_id not in tcurrent: tcurrent.append(source_page_id) tprops[str(reverse_prop["id"])] = tcurrent conn.execute( "UPDATE collection_pages SET property_values_json=? WHERE id=?", (json.dumps(tprops), target_page_id), ) conn.commit() return {"status": "linked", "source": source_page_id, "target": target_page_id} @router.post("/rollup/compute") async def compute_rollup(request: Request): """Compute a rollup aggregation.""" try: body = await request.json() except Exception: body = {} collection_id = body.get("collection_id") relation_property_id = body.get("relation_property_id") target_property_id = body.get("target_property_id") page_id = body.get("page_id") rollup_function = body.get("function", "count") if not all([collection_id, relation_property_id, target_property_id, page_id]): raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required") from app.services.rollup_engine import RollupEngine engine = RollupEngine() result = engine.compute( collection_id, relation_property_id, target_property_id, page_id, rollup_function, ) return {"result": result, "function": rollup_function} @router.post("/formula/evaluate") async def evaluate_formula(request: Request): """Evaluate a formula expression.""" try: body = await request.json() except Exception: body = {} expression = body.get("expression", "") context = body.get("context", {}) if not expression: raise HTTPException(status_code=400, detail="expression is required") from app.services.formula_engine import FormulaEngine engine = FormulaEngine() result = engine.evaluate(expression, context) return {"result": result, "expression": expression} # ── v1.7.0 View Management ── @router.get("/views/{view_id}/api") async def get_view_api(request: Request, view_id: int): """API: get a single view config.""" with get_conn() as conn: row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="View not found") return dict(row) @router.put("/views/{view_id}/config") async def update_view_config(request: Request, view_id: int): """API: update view configuration (group_by, card_size, visible_properties, etc.).""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not existing: raise HTTPException(status_code=404, detail="View not found") config = json.loads(existing["config_json"]) for key in ("group_by", "card_size", "cover_property", "visible_properties", "filters", "sorts", "filter_conjunction", "date_property", "date_range_property"): if key in body: config[key] = body[key] conn.execute( "UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?", (json.dumps(config), body.get("name"), view_id), ) conn.commit() return {"id": view_id, "status": "updated", "config": config} @router.post("/{collection_id}/views/save-as") async def save_view_as(request: Request, collection_id: int): """API: save current view state as a new named view.""" try: body = await request.json() except Exception: body = {} name = body.get("name", "New View") config = body.get("config", {}) with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (collection_id,), ).fetchone()[0] cur = conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos), ) conn.commit() return {"id": cur.lastrowid, "name": name, "status": "saved"} @router.get("/{collection_id}/views/api") async def list_views_api(request: Request, collection_id: int): """API: list all views for a collection.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return {"views": [dict(r) for r in rows]} # ── v1.8.0 Sub-items & Dependencies ── @router.get("/{collection_id}/pages/{page_id}/sub-items") async def list_sub_items(request: Request, collection_id: int, page_id: int): """API: list sub-items of a page.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,), ).fetchall() return {"sub_items": [dict(r) for r in rows]} @router.post("/{collection_id}/pages/{page_id}/sub-items") async def create_sub_item(request: Request, collection_id: int, page_id: int): """API: create a sub-item under a page.""" try: body = await request.json() except Exception: body = {} title = body.get("title", "New sub-item").strip() if not title: raise HTTPException(status_code=400, detail="title is required") with get_conn() as conn: parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone() if not parent: raise HTTPException(status_code=404, detail="Parent page not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (page_id,), ).fetchone()[0] cur = conn.execute( "INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))), ) conn.commit() return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"} @router.get("/{collection_id}/pages/{page_id}/status-aggregate") async def aggregate_child_status(request: Request, collection_id: int, page_id: int): """API: compute aggregate status from children.""" with get_conn() as conn: children = conn.execute( "SELECT property_values_json FROM collection_pages WHERE parent_id=?", (page_id,), ).fetchall() statuses = [] for c in children: props = json.loads(c["property_values_json"]) for v in props.values(): if isinstance(v, str) and v: statuses.append(v) total = len(statuses) if total == 0: return {"total": 0, "done": 0, "all_done": False} done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé")) return {"total": total, "done": done, "all_done": done == total} @router.post("/{collection_id}/pages/{page_id}/dependencies") async def set_dependencies(request: Request, collection_id: int, page_id: int): """API: set blocking dependencies for a page (stored as 'blocks' property).""" try: body = await request.json() except Exception: body = {} blocks_ids = body.get("blocks", []) with get_conn() as conn: page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") props = json.loads(page["property_values_json"]) props["blocks"] = blocks_ids conn.execute( "UPDATE collection_pages SET property_values_json=? WHERE id=?", (json.dumps(props), page_id), ) conn.commit() return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"} @router.post("/{collection_id}/pages/{page_id}/check-deps") async def check_dependencies(request: Request, collection_id: int, page_id: int): """API: check if a page can transition to a new status.""" try: body = await request.json() except Exception: body = {} new_status = body.get("new_status", "Done") with get_conn() as conn: page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") props = json.loads(page["property_values_json"]) blocks_ids = props.get("blocks", []) if not blocks_ids: return {"can_transition": True, "blocked_by": []} # Check blocked pages status placeholders = ",".join("?" for _ in blocks_ids) blocked = conn.execute( f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})", blocks_ids, ).fetchall() blockers = [] for b in blocked: bprops = json.loads(b["property_values_json"]) bstatus = None for v in bprops.values(): if isinstance(v, str) and v: bstatus = v break if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"): blockers.append({"id": b["id"], "title": b["title"], "status": bstatus}) return { "can_transition": len(blockers) == 0, "blocked_by": blockers, } # ── v4.1.0: Data Sources & Linked Databases ── @router.get("/{collection_id}/sources/api") async def list_data_sources(request: Request, collection_id: int): """API: list all data sources for a collection.""" with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") rows = conn.execute( "SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return {"sources": [dict(r) for r in rows]} @router.post("/{collection_id}/sources/api") async def add_data_source(request: Request, collection_id: int): """API: add a data source to a collection.""" try: body = await request.json() except Exception: body = {} source_collection_id = body.get("source_collection_id") if not source_collection_id: raise HTTPException(status_code=400, detail="source_collection_id is required") source_name = body.get("source_name", "").strip() is_linked = body.get("is_linked", False) with get_conn() as conn: # Verify both collections exist for cid in (collection_id, source_collection_id): if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone(): raise HTTPException(status_code=404, detail=f"Collection {cid} not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?", (collection_id,), ).fetchone()[0] try: cur = conn.execute( """INSERT INTO collection_data_sources (collection_id, source_collection_id, source_name, is_linked, position) VALUES (?, ?, ?, ?, ?)""", (collection_id, source_collection_id, source_name, int(is_linked), max_pos), ) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="This data source already exists in this collection") return { "id": cur.lastrowid, "collection_id": collection_id, "source_collection_id": source_collection_id, "status": "added", } @router.delete("/{collection_id}/sources/{source_id}/api") async def remove_data_source(request: Request, collection_id: int, source_id: int): """API: remove a data source from a collection.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?", (source_id, collection_id), ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Data source not found") conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,)) conn.commit() return {"id": source_id, "status": "removed"} @router.post("/{collection_id}/linked/api") async def create_linked_database(request: Request, collection_id: int): """API: create a linked database view from a source collection. A linked database copies the structure (views, filters, sorts) of a source but shares the same pages — edits to pages propagate to the source. """ try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() new_workspace_id = body.get("workspace_id") with get_conn() as conn: source = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not source: raise HTTPException(status_code=404, detail="Source collection not found") if not name: name = f"{source['name']} (linked)" # Create the linked collection (shallow copy of structure) # Inherit workspace_id from source for permission inheritance src_dict = dict(source) source_workspace_id = src_dict.get("workspace_id") cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( name, src_dict["description"], src_dict["icon"], src_dict["schema_json"], 0, # linked DB is never locked 1, # linked DB starts as inline src_dict.get("parent_page_id"), source_workspace_id, # linked DB inherits source workspace permissions ), ) linked_id = cur.lastrowid # Copy views from source views = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() for v in views: conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (linked_id, v["name"], v["view_type"], v["config_json"], v["position"]), ) # Add the source as a data source with is_linked=1 conn.execute( """INSERT INTO collection_data_sources (collection_id, source_collection_id, source_name, is_linked, position) VALUES (?, ?, ?, 1, 0)""", (linked_id, collection_id, source["name"]), ) # Copy properties from source props = conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() for p in props: conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, related_collection_id, reverse_name, relation_property_id, target_property_id, rollup_function, formula_expression, position, required, visible_in_views) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( linked_id, p["name"], p["prop_type"], p["options_json"], p["number_format"], p["related_collection_id"], p["reverse_name"], p["relation_property_id"], p["target_property_id"], p["rollup_function"], p["formula_expression"], p["position"], p["required"], p["visible_in_views"], ), ) conn.commit() return { "linked_id": linked_id, "name": name, "source_collection_id": collection_id, "status": "created", } @router.post("/{collection_id}/toggle-inline/api") async def toggle_inline(request: Request, collection_id: int): """API: toggle a collection between full-page and inline mode.""" with get_conn() as conn: coll = conn.execute( "SELECT id, is_inline FROM collections WHERE id=?", (collection_id,), ).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") new_inline = 0 if coll["is_inline"] else 1 conn.execute( "UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_inline, collection_id), ) conn.commit() return { "collection_id": collection_id, "is_inline": bool(new_inline), "mode": "inline" if new_inline else "full-page", } @router.post("/inline/api") async def create_inline_database(request: Request): """API: create an inline database within a parent page (optionally from a template).""" try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") description = body.get("description", "") icon = body.get("icon", "📋") parent_page_id = body.get("parent_page_id") workspace_id = body.get("workspace_id") schema = body.get("schema", []) with get_conn() as conn: tpl = _apply_template(conn, body.get("template")) if tpl: if body.get("name"): name = body["name"].strip() description = tpl["description"] icon = tpl.get("icon") or icon try: schema = json.loads(tpl["schema_json"]) except (json.JSONDecodeError, TypeError): schema = [] cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) VALUES (?, ?, ?, ?, 1, ?, ?)""", (name, description, icon, json.dumps(schema), parent_page_id, workspace_id), ) collection_id = cur.lastrowid materialize_properties(conn, collection_id, schema) # Create default view conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)""", (collection_id, "Default View", "table", json.dumps({ "visible_properties": ["Title"], "sorts": [], "filters": [], })), ) conn.commit() return { "id": collection_id, "name": name, "icon": icon, "is_inline": True, "parent_page_id": parent_page_id, "status": "created", } # ── v4.4.0: Tasks & Dependencies ── @router.put("/{collection_id}/toggle-task/api") async def toggle_task(request: Request, collection_id: int): """API: toggle is_task flag on a collection (Turn into Tasks).""" with get_conn() as conn: coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") new_val = 0 if coll["is_task"] else 1 conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id)) conn.commit() return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"} @router.get("/{collection_id}/pages/{page_id}/dependencies/api") async def list_page_dependencies(request: Request, collection_id: int, page_id: int): """API: list dependencies for a page (blocks, blocked_by, related).""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at", (page_id,), ).fetchall() deps = [] for r in rows: d = dict(r) dep_page = conn.execute( "SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],) ).fetchone() if dep_page: d["dependency_title"] = dep_page["title"] deps.append(d) return {"dependencies": deps} @router.post("/{collection_id}/pages/{page_id}/dependencies/api") async def add_page_dependency(request: Request, collection_id: int, page_id: int): """API: add a dependency (blocks/blocked_by/related) between two pages.""" try: body = await request.json() except Exception: body = {} dependency_id = body.get("dependency_id") if not dependency_id: raise HTTPException(status_code=400, detail="dependency_id is required") dep_type = body.get("dependency_type", "blocks") auto_shift = body.get("auto_shift", "overlap") with get_conn() as conn: for pid in (page_id, dependency_id): if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone(): raise HTTPException(status_code=404, detail=f"Page {pid} not found") try: cur = conn.execute( "INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)", (page_id, dependency_id, dep_type, auto_shift), ) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="This dependency already exists") return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"} @router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api") async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int): """API: remove a dependency.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Dependency not found") conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,)) conn.commit() return {"id": dep_id, "status": "removed"} @router.post("/{collection_id}/pages/{page_id}/auto-shift/api") async def auto_shift_dates(request: Request, collection_id: int, page_id: int): """API: auto-shift dates based on blocking dependencies.""" from datetime import date as dt_date, timedelta try: body = await request.json() except Exception: body = {} skip_weekends = body.get("skip_weekends", False) with get_conn() as conn: page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") # Get all blocking dependencies deps = conn.execute( "SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'", (page_id,), ).fetchall() shifted = False new_start = None for dep in deps: dep_page = conn.execute( "SELECT title, property_values_json FROM collection_pages WHERE id=?", (dep["dependency_id"],), ).fetchone() if not dep_page: continue dep_props = json.loads(dep_page["property_values_json"]) # Find the latest end date among blockers for v in dep_props.values(): if isinstance(v, str) and v.startswith("20"): end_date = v.split("...")[-1].split("→")[-1].strip()[:10] try: ed = dt_date.fromisoformat(end_date) if new_start is None or ed >= new_start: new_start = ed + timedelta(days=1) shifted = True except ValueError: continue if not shifted: return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"} # Skip weekends if requested if skip_weekends and new_start: while new_start.weekday() >= 5: # 5=Sat, 6=Sun new_start = new_start + timedelta(days=1) # Update the page's date properties props = json.loads(page["property_values_json"]) for k, v in list(props.items()): if isinstance(v, str) and v.startswith("20"): old_parts = v.split("...") old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0] try: old_start_d = dt_date.fromisoformat(old_parts[0][:10]) old_end_d = dt_date.fromisoformat(old_end[:10]) duration = (old_end_d - old_start_d).days new_end = new_start + timedelta(days=max(duration, 0)) props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}" except ValueError: props[k] = new_start.isoformat() break conn.execute( "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(props), page_id), ) conn.commit() return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends} # ── {collection_id} wildcards (LAST — catches everything else) ── @router.get("/{collection_id}", response_class=HTMLResponse) @router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse) async def view_collection(request: Request, collection_id: int, view_type: str = "table"): """Main view — renders collection in the requested view type.""" with get_conn() as conn: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not collection: raise HTTPException(status_code=404, detail="Collection not found") view = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1", (collection_id,), ).fetchone() pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() collection_dict = dict(collection) pages_list = [dict(p) for p in pages] config = json.loads(view["config_json"]) if view else {} if "year" in request.query_params: config["year"] = int(request.query_params["year"]) if "month" in request.query_params: config["month"] = int(request.query_params["month"]) return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config)) # ── View renderers (v1.6.0) ── def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: if view_type == "calendar": return _render_calendar(view_type, collection, pages, config) elif view_type == "gallery": return _render_gallery(view_type, collection, pages, config) elif view_type == "list": return _render_list(view_type, collection, pages, config) elif view_type == "timeline": return _render_timeline(view_type, collection, pages, config) elif view_type == "chart": return _render_chart(view_type, collection, pages, config) elif view_type == "form": return _render_form(view_type, collection, pages, config) elif view_type == "map": return _render_map(view_type, collection, pages, config) elif view_type == "feed": return _render_feed(view_type, collection, pages, config) elif view_type == "gantt": return _render_gantt(view_type, collection, pages, config) else: return _render_table(view_type, collection, pages, config) def _base_html(title: str, icon: str, view_type: str, body: str) -> str: return f""" {title} — FlowDeck

{icon} {title}

📊 Table 📋 Board 📅 Calendar 🖼️ Gallery 📝 List 📈 Timeline 📊 Gantt 📉 Chart 📋 Form 🗺️ Map 📰 Feed
{body} """ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: from datetime import date as dt_date, timedelta today = dt_date.today() # Determine month/year from config or current year = config.get("year", today.year) month = config.get("month", today.month) first = dt_date(year, month, 1) # Start from Monday of first week start = first - timedelta(days=first.weekday()) days_in_month = [] for i in range(42): # 6 weeks d = start + timedelta(days=i) days_in_month.append(d) # Map pages to dates date_pages: dict[str, list[dict]] = {} for p in pages: props = json.loads(p.get("property_values_json", "{}")) for v in props.values(): if isinstance(v, str) and v.startswith("20"): d = v[:10] date_pages.setdefault(d, []).append(p) break cells = "" for d in days_in_month: iso = d.isoformat() items = date_pages.get(iso, []) other_month = " other-month" if d.month != month else "" today_class = " today" if d == today else "" items_html = "".join( f"
{p.get('icon','📄')} {p['title'][:20]}
" for p in items ) cells += f"
{d.day}{items_html}
" prev = first - timedelta(days=1) next_month = first + timedelta(days=32) next_month = next_month.replace(day=1) return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
← {prev.strftime('%B')} {first.strftime('%B %Y')} {next_month.strftime('%B')} →
Mon
Tue
Wed
Thu
Fri
Sat
Sun
{cells}

{len(pages)} pages in collection

""") def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: card_size = config.get("card_size", "medium") size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px") cards = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) cover_url = config.get("cover_property") cover_html = "" if cover_url: for k, v in props.items(): if isinstance(v, list) and len(v) > 0: url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) if url.startswith("http"): cover_html = f"
" break prop_tags = "".join( f"{str(v)[:30]}" for v in list(props.values())[:3] if v ) cards += f"""
{cover_html}
{p.get('icon','📄')} {p['title']}
{prop_tags}
""" return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""

{len(pages)} cards

""") def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: items = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v) items += f"""
{p.get('icon','📄')}
{p['title']}
{preview}
""" return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f""" {items}

{len(pages)} items

""") def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: # Find date range dates = [] page_dates = [] for p in pages: props = json.loads(p.get("property_values_json", "{}")) start_val = end_val = None for k, v in props.items(): if isinstance(v, str) and v.startswith("20"): if "..." in v: parts = v.split("...") start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10] else: start_val = end_val = v[:10] break if start_val: dates.append(start_val) if end_val: dates.append(end_val) page_dates.append((p, start_val, end_val or start_val)) if not dates: return _base_html(collection["name"], collection.get("icon", "📈"), view_type, "

No date data to display timeline.

") from datetime import date as dt_date min_date = min(dt_date.fromisoformat(d) for d in dates) max_date = max(dt_date.fromisoformat(d) for d in dates) total = (max_date - min_date).days or 1 bars = "" for p, start, end in page_dates: sd = dt_date.fromisoformat(start) ed = dt_date.fromisoformat(end) left = (sd - min_date).days / total * 100 width = max((ed - sd).days / total * 100, 1) bars += f"""
{p.get('icon','📄')} {p['title']}
""" return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
{min_date} {max_date}
{bars}

{len(pages)} items · {min_date} → {max_date}

""") # ── v4.3.0: New view types ── def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: """Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN.""" chart_type = config.get("chart_type", "bar") chart_property = config.get("chart_property", "") labels = [] values = [] for p in pages: labels.append(p["title"][:30]) props = json.loads(p.get("property_values_json", "{}")) val = 0 if chart_property: v_raw = props.get(chart_property, 0) try: val = float(v_raw) if v_raw else 0 except (ValueError, TypeError): val = 0 values.append(val if val else 1) labels_json = json.dumps(labels) values_json = json.dumps(values) return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""

{len(pages)} entries

""") def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: """Form view — generates an HTML form that creates new pages in the collection.""" from app.db import get_conn properties = [] with get_conn() as conn: props = conn.execute( "SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position", (collection["id"],), ).fetchall() for p in props: prop_dict = dict(p) prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]")) properties.append(prop_dict) fields = "" for prop in properties: name = prop["name"] ptype = prop["prop_type"] if ptype in ("text", "email", "url", "phone", "number"): fields += f"""
""" elif ptype in ("select", "status"): options = "".join(f"" for o in prop.get("options", [])) fields += f"""
""" elif ptype == "checkbox": fields += f"""
""" elif ptype == "date": fields += f"""
""" return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""

New entry in {collection['name']}

{fields}
""") def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: """Map view — displays pages with location data on Leaflet map.""" markers = [] for p in pages: props = json.loads(p.get("property_values_json", "{}")) lat, lng = None, None for k, v in props.items(): if isinstance(v, str) and "," in v: parts = v.split(",") try: lat, lng = float(parts[0].strip()), float(parts[1].strip()) except ValueError: continue elif isinstance(v, dict): lat = v.get("lat") lng = v.get("lng") if lat and lng: markers.append({"title": p["title"], "lat": lat, "lng": lng}) markers_json = json.dumps(markers) center_lat = markers[0]["lat"] if markers else 45.5 center_lng = markers[0]["lng"] if markers else -73.5 return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""

{len(markers)} location(s) mapped

""") def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: """Feed view — chronological feed of pages, newest first.""" sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True) items = "" for p in sorted_pages: created = p.get("created_at", "")[:10] if p.get("created_at") else "" items += f"""
{created}
{p.get('icon','📄')} {p['title']}
""" return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f""" {items}

{len(sorted_pages)} entries

""") def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: """Gantt view — timeline with dependencies and group_by support.""" group_by = config.get("group_by", "") gantt_data = [] for p in pages: props = json.loads(p.get("property_values_json", "{}")) group = None start_val = end_val = None for k, v in props.items(): if isinstance(v, str) and v.startswith("20"): if "→" in v: parts = v.split("→") start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10] elif "..." in v: parts = v.split("...") start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10] else: start_val = end_val = v[:10] break if group_by: group = str(props.get(group_by, props.get("Status", "")))[:20] if start_val: gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""}) if not gantt_data: return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "

No date data for Gantt chart.

") from datetime import date as dt_date_2 all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data] min_date = min(dt_date_2.fromisoformat(d) for d in all_dates) max_date = max(dt_date_2.fromisoformat(d) for d in all_dates) total_days = max((max_date - min_date).days, 1) groups = {} for d in gantt_data: groups.setdefault(d["group"], []).append(d) if not groups or all(k == "" for k in groups): groups = {"": gantt_data} rows = "" for group_name, items in sorted(groups.items()): if group_name: rows += f"
{group_name} ({len(items)})
" for item in items: sd = dt_date_2.fromisoformat(item["start"]) ed = dt_date_2.fromisoformat(item["end"]) left = max((sd - min_date).days / total_days * 100, 0) width = max((ed - sd).days / total_days * 100, 1) rows += f"""
{item['title'][:30]}
{item['start']} → {item['end']}
""" return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
{min_date} {max_date}
{rows}

{len(gantt_data)} items · {min_date} → {max_date}

""") def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: rows = "" for p in pages: props = json.loads(p.get("property_values_json", "{}")) prop_cells = "".join(f"{str(v)[:80]}" for v in list(props.values())[:4]) rows += f"{p.get('icon','📄')}{p['title']}{prop_cells}" return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f""" {rows}
TitleProperties

{len(pages)} rows

""") @router.get("/{collection_id}/api") async def get_collection_api(request: Request, collection_id: int): """API: get a single collection with its pages.""" with get_conn() as conn: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not collection: raise HTTPException(status_code=404, detail="Collection not found") pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() views = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return { "collection": dict(collection), "pages": [dict(p) for p in pages], "views": [dict(v) for v in views], } @router.post("/{collection_id}/pages/api") async def create_page_api(request: Request, collection_id: int): """API: create a page in a collection.""" try: body = await request.json() except Exception: body = {} title = body.get("title", "").strip() if not title: raise HTTPException(status_code=400, detail="title is required") icon = body.get("icon", "📄") property_values = body.get("properties", {}) gitea_issue_id = body.get("gitea_issue_id") gitea_issue_number = body.get("gitea_issue_number") with get_conn() as conn: coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") _validate_page_properties(conn, collection_id, property_values) max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,), ).fetchone()[0] cur = conn.execute( """INSERT INTO collection_pages (collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json) VALUES (?, ?, ?, ?, ?, ?, ?)""", (collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number, json.dumps(property_values)), ) conn.commit() page_id = cur.lastrowid return {"id": page_id, "title": title, "status": "created"}