"""FlowDeck — Collections router: Notion-style databases (v1.3.0).""" from __future__ import annotations import json import logging from fastapi import APIRouter, Request, HTTPException, Query from fastapi.responses import HTMLResponse from app.db import get_conn from app.auth.session import SessionManager logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") # ── 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.""" 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_json = json.dumps(body.get("schema", [])) is_locked = body.get("is_locked", False) with get_conn() as conn: 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 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"]) property_values = json.dumps( body.get("properties", json.loads(existing["property_values_json"])) ) 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)) 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (collection_id, name, prop_type, options_json, number_format, max_pos, required, visible), ) 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"])) conn.execute( """UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=? WHERE id=?""", (name, options_json, number_format, required, visible, 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, } # ── {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 {} 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) 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
{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}

""") 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 = json.dumps(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") 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, property_values), ) conn.commit() page_id = cur.lastrowid return {"id": page_id, "title": title, "status": "created"}