"""FlowDeck — Collections router: Notion-style databases (v1.3.0).""" from __future__ import annotations import html as _htmlmod import json import logging import sqlite3 from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.automations import fire_event from app.services.db_templates import materialize_properties from app.services.permission_manager import PermissionManager from app.services.property_types import ( AUTO_TYPES, apply_auto_properties, validate_property_rule, ) from app.services.recurrence import ( RECURRENCE_KEY, expand_rule, is_valid_timezone, parse_date, validate_rule, ) from app.services.reminders import REMINDER_KEY, parse_lead def _current_user(request: Request) -> dict: """Resolve the session user, falling back to the local admin (single-user).""" s = request.cookies.get("flowdeck_session", "") return SessionManager.decode_session(s) or {"login": "admin", "id": 1} def _session_user(request: Request) -> dict | None: """Resolve the session user WITHOUT the admin fallback (for ACL checks).""" s = request.cookies.get("flowdeck_session", "") user = SessionManager.decode_session(s) return user if user and user.get("id") else None def _require_view(collection_id: int, user: dict | None) -> None: """Raise 404 when the user may not view the collection (404 hides it). A6 : plus de session = accès refusé — l'absence de user ne vaut plus « legacy single-user » ( lecture anonyme de n'importe quelle collection ). """ if not user: raise HTTPException(status_code=404, detail="Collection not found") pm = PermissionManager(user["id"]) if not pm.can_view_collection(collection_id): raise HTTPException(status_code=404, detail="Collection not found") def _require_edit(collection_id: int, user: dict | None) -> None: """Raise 401/403 when the user may not edit pages in the collection.""" if not user: raise HTTPException(status_code=401, detail="Authentication required") pm = PermissionManager(user["id"]) if not pm.can_edit_collection(collection_id): raise HTTPException(status_code=403, detail="You don't have edit access to this collection") def _collection_properties(conn, collection_id: int) -> list[dict]: return [ dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() ] 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" or ptype in AUTO_TYPES: 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}") def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None: """Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored alongside real property values. Raises HTTPException(400) on bad shape. Each meta key maps a date-property id to a rule/reminder object. We verify the target is actually a date property and the payload parses. """ date_ids = { str(r["id"]) for r in conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'", (collection_id,), ).fetchall() } rec = properties.get(RECURRENCE_KEY) if rec not in (None, {}): if not isinstance(rec, dict): raise HTTPException(status_code=400, detail="Recurrence must be an object") for prop_id, rule in rec.items(): if rule is None: continue if str(prop_id) not in date_ids: raise HTTPException(status_code=400, detail="Recurrence target must be a date property") ok, msg = validate_rule(rule) if not ok: raise HTTPException(status_code=400, detail=f"Recurrence: {msg}") rem = properties.get(REMINDER_KEY) if rem not in (None, {}): if not isinstance(rem, dict): raise HTTPException(status_code=400, detail="Reminder must be an object") for prop_id, reminder in rem.items(): if reminder is None: continue if str(prop_id) not in date_ids: raise HTTPException(status_code=400, detail="Reminder target must be a date property") if not isinstance(reminder, dict): raise HTTPException(status_code=400, detail="Reminder must be an object") if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"): raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none") if parse_lead(reminder) is None and reminder.get("unit") != "none": raise HTTPException(status_code=400, detail="Reminder value must be a positive integer") from app.services.recurrence import TIMEZONE_KEY tzmap = properties.get(TIMEZONE_KEY) if tzmap not in (None, {}): if not isinstance(tzmap, dict): raise HTTPException(status_code=400, detail="Timezone map must be an object") for prop_id, value in tzmap.items(): if str(prop_id) not in date_ids: raise HTTPException(status_code=400, detail="Timezone target must be a date property") if value and not is_valid_timezone(str(value)): raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'") # ── 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() await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}) 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() await fire_event("collection.updated", {"collection_id": collection_id, "name": name}) 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).""" # v6.0.0: granular collection permissions — owner/admin only. user = _session_user(request) _require_view(collection_id, user) if user: pm = PermissionManager(user["id"]) if not pm.can_manage_collection_permissions(collection_id): raise HTTPException(status_code=403, detail="Only a collection owner can delete it") 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() await fire_event("collection.deleted", {"collection_id": collection_id, "name": existing["name"] if existing else ""}) return {"id": collection_id, "status": "deleted"} @router.post("/{collection_id}/duplicate") async def duplicate_collection_api(request: Request, collection_id: int): """v5.4.0: deep-duplicate a database (views + properties + pages + data sources) into a new collection named ' (copy)'.""" with get_conn() as conn: src = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not src: raise HTTPException(status_code=404, detail="Collection not found") new_name = (src["name"] or "Database") + " copy" cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked, is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at) SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked, is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at FROM collections WHERE id=?""", (new_name, collection_id), ) new_id = cur.lastrowid # ── Properties (remap ids so relation/rollup refs stay valid) ── prop_map: dict[int, int] = {} rows = conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() # A23 : un seul executemany ; les rowid sont contigus (même transaction, # insertion dans l'ordre de `rows`), donc le mappeur se fait par index. tuples = [ (new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"], None, p["reverse_name"], None, None, p["rollup_function"], p["formula_expression"], p["position"], p["required"], p["visible_in_views"]) for p in rows ] if tuples: ncur = conn.executemany( """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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", tuples, ) new_ids = [ r["id"] for r in conn.execute( "SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id", (new_id,), ).fetchall() ] assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu" for p, new_pid in zip(rows, new_ids, strict=True): prop_map[p["id"]] = new_pid # Fix cross-property references after all rows exist (creates may target # columns not inserted yet). Related collection remapped to the copy. for p in rows: p = dict(p) # convert sqlite3.Row to dict new_pid = prop_map[p["id"]] related = p["related_collection_id"] related_new = new_id if related == collection_id else related if p["prop_type"] == "relation": conn.execute( "UPDATE collection_properties SET related_collection_id=? WHERE id=?", (related_new, new_pid), ) if p.get("relation_property_id") and p["relation_property_id"] in prop_map: conn.execute( "UPDATE collection_properties SET relation_property_id=? WHERE id=?", (prop_map[p["relation_property_id"]], new_pid), ) if p.get("target_property_id") and p["target_property_id"] in prop_map: conn.execute( "UPDATE collection_properties SET target_property_id=? WHERE id=?", (prop_map[p["target_property_id"]], new_pid), ) # ── Views ── vrows = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() for v in vrows: conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)""", (new_id, v["name"], v["view_type"], v["config_json"], v["position"]), ) # ── Pages (rows) with property ids remapped to the copy's properties ── prows = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() page_map: dict[int, int] = {} for p in prows: try: pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {} except (json.JSONDecodeError, TypeError): pv = {} pv_new = {} for k, val in pv.items(): try: prop_id = int(k) except (ValueError, TypeError): prop_id = None new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k pv_new[new_key] = val ncur = conn.execute( """INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, gitea_issue_id, gitea_issue_number, property_values_json, created_at, updated_at) SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at FROM collection_pages WHERE id=?""", (new_id, json.dumps(pv_new), p["id"]), ) page_map[p["id"]] = ncur.lastrowid # Re-parent sub-items to the copied rows. for p in prows: if p["parent_id"] and p["parent_id"] in page_map: conn.execute( "UPDATE collection_pages SET parent_id=? WHERE id=?", (page_map[p["parent_id"]], page_map[p["id"]]), ) # ── Data sources (linked DBs) ── drows = conn.execute( "SELECT * FROM collection_data_sources WHERE collection_id=?", (collection_id,), ).fetchall() for d in drows: src_coll = d["source_collection_id"] src_now = new_id if src_coll == collection_id else src_coll conn.execute( """INSERT INTO collection_data_sources (collection_id, source_collection_id, source_name, is_linked, position) VALUES (?,?,?,?,?)""", (new_id, src_now, d["source_name"], d["is_linked"], d["position"]), ) conn.commit() await fire_event("collection.created", {"collection_id": new_id, "name": new_name}) return {"id": new_id, "name": new_name, "status": "duplicated"} # ── 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") # v6.0.0: granular collection/page permissions. _require_view(page["collection_id"], _session_user(request)) return dict(page) @router.get("/pages/{page_id}/open/api") async def open_row_page_api(request: Request, page_id: int): """v6.5.0 — content page of a database row (lazy-created). Any DB view (table/board/gallery/list/calendar) opens a row through this endpoint: it returns the shadow ``pages`` id whose full page editor carries the row's block content (synced blocks included). """ with get_conn() as conn: row = conn.execute( "SELECT collection_id FROM collection_pages WHERE id=?", (page_id,), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Page not found") coll_id = row["collection_id"] # v6.0.0: granular collection permissions (same gate as the row itself). _require_view(coll_id, _session_user(request)) from app.services.row_pages import ensure_row_page try: content_page_id = ensure_row_page(page_id) except KeyError: raise HTTPException(status_code=404, detail="Page not found") from None return {"page_id": content_page_id, "row_id": page_id} @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") # v6.0.0: granular collection/page permissions. _require_edit(existing["collection_id"], _session_user(request)) title = body.get("title", existing["title"]) icon = body.get("icon", existing["icon"]) cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "") position = body.get("position", existing["position"]) parent_id = body.get("parent_id", existing["parent_id"]) try: stored = json.loads(existing["property_values_json"]) except (json.JSONDecodeError, TypeError): stored = {} if "properties" in body: # Partial PATCH semantics: merge submitted values over stored ones. props = dict(stored) props.update(body["properties"]) else: props = stored _validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id) _validate_meta_keys(conn, existing["collection_id"], props) apply_auto_properties( _collection_properties(conn, existing["collection_id"]), props, _current_user(request), is_create=False, ) property_values = json.dumps(props) conn.execute( """UPDATE collection_pages SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?""", (title, icon, cover_url, position, parent_id, property_values, page_id), ) # v6.5.0: keep the row's content page title in sync (row → page). from app.services.row_pages import sync_row_title_to_page sync_row_title_to_page(conn, page_id) conn.commit() await fire_event("page.updated", { "page_id": page_id, "collection_id": existing["collection_id"], "title": title, "icon": icon, "properties": props, }) await fire_event("collection.page.updated", { "page_id": page_id, "collection_id": existing["collection_id"], "title": title, }) # Notify newly assigned people (person properties) — v5.8.0. from app.services.notifications import notify_assignment user = _current_user(request) notify_assignment(existing["collection_id"], page_id, title, stored, props, user.get("id")) 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") # v6.0.0: granular collection/page permissions. _require_edit(existing["collection_id"], _session_user(request)) conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() await fire_event("page.deleted", { "page_id": page_id, "collection_id": existing["collection_id"], "title": existing["title"], }) await fire_event("collection.page.deleted", { "page_id": page_id, "collection_id": existing["collection_id"], }) 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 visible to the current user.""" user = _session_user(request) _require_view(collection_id, user) 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() props = [dict(r) for r in rows] # v6.0.0: property-level visibility — owners/editors see everything, other # users only the properties explicitly granted or left open. if user: pm = PermissionManager(user["id"]) visible = pm.get_visible_properties(collection_id) props = [p for p in props if p["id"] in visible] return {"properties": props} @router.get("/{collection_id}/members/api") async def list_collection_members_api(request: Request, collection_id: int): """API: list workspace members available for a ``person`` property. Resolves the collection's workspace and returns its members (falling back to every active user for standalone databases without a workspace). """ with get_conn() as conn: coll = conn.execute( "SELECT workspace_id FROM collections WHERE id=?", (collection_id,) ).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None if ws_id: rows = conn.execute( """SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""", (ws_id,), ).fetchall() else: rows = [] if not rows: rows = conn.execute( """SELECT id, login, full_name, avatar_url, avatar_color, '' AS role FROM users WHERE is_active=1 ORDER BY full_name, login""" ).fetchall() return {"members": [dict(r) for r in rows]} @router.get("/{collection_id}/calendar/api") async def collection_calendar_api(request: Request, collection_id: int, start: str = "", end: str = "", date_property: str = ""): """API (v5.8.0): expanded calendar events for a window [start, end]. Returns every occurrence (recurrence-aware, virtual — never persisted) of the rows in the collection whose ``date_property`` falls inside the inclusive window. Rows without a rule yield their base date. """ user = _current_user(request) s = parse_date(start) e = parse_date(end) if s is None or e is None or s > e: raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD") if (e - s).days > 370: raise HTTPException(status_code=400, detail="window too large (max 370 days)") 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") props = _collection_properties(conn, collection_id) date_props = [p for p in props if p["prop_type"] == "date"] target = None if date_property: target = next((p for p in date_props if str(p["id"]) == str(date_property) or p["name"] == date_property), None) if target is None: raise HTTPException(status_code=400, detail="Unknown date property") elif date_props: target = date_props[0] if target is None: return {"events": [], "timezone": "", "property": None} urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone() user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or "" rows = conn.execute( "SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?", (collection_id,), ).fetchall() pid = str(target["id"]) events: list[dict] = [] for r in rows: try: pv = json.loads(r["property_values_json"] or "{}") except (json.JSONDecodeError, TypeError): continue base_value = pv.get(pid) if base_value is None: base_value = pv.get(target["name"]) if parse_date(base_value) is None: continue rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {} rule = rec_all.get(pid) or rec_all.get(target["name"]) row_tz = user_tz if isinstance(rule, dict) and rule.get("timezone"): row_tz = rule["timezone"] tzmap = pv.get("__timezone__") if isinstance(tzmap, dict): ev_tz = tzmap.get(pid) or tzmap.get(target["name"]) if ev_tz: row_tz = str(ev_tz) if rule: dates = expand_rule(base_value, rule, s, e, max_occurrences=500) else: d = parse_date(base_value) dates = [d.isoformat()] if d and s <= d <= e else [] for iso in dates: events.append({ "date": iso, "page_id": r["id"], "title": r["title"], "icon": r["icon"], "recurring": bool(rule), "time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "", "timezone": row_tz, }) events.sort(key=lambda ev: (ev["date"], ev["page_id"])) return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}} @router.get("/timezones/api") async def timezones_api(request: Request): """API (v5.8.0): the user's timezone plus a picker-friendly zone list.""" user = _current_user(request) with get_conn() as conn: row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone() from app.services.recurrence import common_timezones return { "timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "", "zones": common_timezones(), } @router.post("/{collection_id}/property-groups/api") async def set_property_groups_api(request: Request, collection_id: int): """API: (re)assign properties to collapsible groups in the table header. Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties omitted from any group have their group cleared. Empty group names clear. """ try: body = await request.json() except Exception: body = {} groups = body.get("groups", []) 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") conn.execute( "UPDATE collection_properties SET group_name='' WHERE collection_id=?", (collection_id,), ) for grp in groups: gname = (grp.get("name") or "").strip() if not gname: continue for pid in grp.get("property_ids", []) or []: conn.execute( "UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?", (gname, pid, collection_id), ) conn.commit() return {"status": "updated"} @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", {})) group_name = (body.get("group_name") or "").strip() 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, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (collection_id, name, prop_type, options_json, number_format, max_pos, required, visible, validation_json, group_name), ) conn.commit() except Exception: raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "group_name": group_name, "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 "{}" group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "") conn.execute( """UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?""", (name, options_json, number_format, required, visible, validation_json, group_name, 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", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "filter_conjunction", "date_property", "date_range_property", "property_groups", "view_type"): if key in body: config[key] = body[key] new_type = body.get("view_type") or existing["view_type"] conn.execute( "UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(config), body.get("name"), new_type, view_id), ) conn.commit() return {"id": view_id, "status": "updated", "config": config, "view_type": new_type} @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", {}) user = _current_user(request) user_id = user.get("id") if user else None 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] view_type = body.get("view_type", "table") cur = conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)""", (collection_id, name, view_type, json.dumps(config), max_pos, user_id), ) conn.commit() new_view_id = cur.lastrowid await fire_event("collection.view.created", { "view_id": new_view_id, "collection_id": collection_id, "name": name, "view_type": view_type, }) return {"id": new_view_id, "name": name, "view_type": view_type, "config_json": json.dumps(config), "created_by": user_id, "status": "saved"} @router.get("/{collection_id}/views/api") async def list_views_api(request: Request, collection_id: int): """API: list views for a collection visible to the current user. Shared/legacy views (``created_by IS NULL``) are visible to everyone; personal views (``created_by = user``) only to their owner. """ user = _current_user(request) user_id = user.get("id") if user else None with get_conn() as conn: if user_id is not None: rows = conn.execute( """SELECT * FROM collection_views WHERE collection_id=? AND (created_by IS NULL OR created_by=?) ORDER BY position""", (collection_id, user_id), ).fetchall() else: rows = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position", (collection_id,), ).fetchall() return {"views": [dict(r) for r in rows]} @router.delete("/views/{view_id}/api") async def delete_view_api(request: Request, view_id: int): """API: delete a saved view.""" 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") conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,)) conn.commit() return {"id": view_id, "status": "deleted"} @router.post("/views/{view_id}/duplicate") async def duplicate_view_api(request: Request, view_id: int): """API: duplicate a view (config + type), owned by the current user.""" 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") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (existing["collection_id"],), ).fetchone()[0] user = _current_user(request) user_id = user.get("id") if user else None name = body.get("name") or (existing["name"] + " copy") cur = conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)""", (existing["collection_id"], name, existing["view_type"], existing["config_json"], max_pos, user_id), ) conn.commit() dup_view_id = cur.lastrowid await fire_event("collection.view.created", { "view_id": dup_view_id, "collection_id": existing["collection_id"], "name": name, "view_type": existing["view_type"], }) return {"id": dup_view_id, "name": name, "view_type": existing["view_type"], "status": "duplicated"} # ── 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] sub_props = body.get("properties", {}) or {} apply_auto_properties( _collection_properties(conn, collection_id), sub_props, _current_user(request), is_create=True, ) 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(sub_props)), ) conn.commit() new_id = cur.lastrowid await fire_event("page.created", { "page_id": new_id, "collection_id": collection_id, "parent_id": page_id, "title": title, "properties": body.get("properties", {}), }) await fire_event("collection.page.created", { "page_id": new_id, "collection_id": collection_id, "title": title, }) return {"id": new_id, "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 = {} 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") from None 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() 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") from None 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 from datetime import 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}/dashboards/{dashboard_id}", response_class=HTMLResponse) async def view_dashboard(request: Request, collection_id: int, dashboard_id: int): """v7.3.0: render a collection dashboard grid (multi-DB widgets). Widgets live in ``collection_dashboards.layout_json`` as ``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?, chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may point at *any* database (the dashboard's own collection is the default), which is what "dashboards multi-DB" means. """ uid = _session_user(request) _require_view(collection_id, uid) with get_conn() as conn: dash = conn.execute( "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (dashboard_id, collection_id)).fetchone() if not dash: raise HTTPException(404, "Dashboard not found") layout = json.loads(dash["layout_json"] or "{}") columns = max(1, int(layout.get("columns", 1) or 1)) widgets = layout.get("widgets", []) or [] if not isinstance(widgets, list): widgets = [] rendered = [] for w in widgets[:40]: if not isinstance(w, dict): continue wc = int(w.get("collection_id") or 0) or collection_id with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone() if not coll: continue try: _require_view(wc, uid) except HTTPException: continue # restricted database → widget skipped, not rendered with get_conn() as conn: wpages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?", (wc, CHART_MAX_GROUPS)).fetchall() wconfig = {k: v for k, v in w.items() if k in ("chart_type", "chart_property", "aggregate", "title")} view_type = w.get("view_type") or "chart" if view_type == "chart": body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig) else: body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig) width = int(w.get("width") or 0) span = f"grid-column: span {width};" if width and width > 0 else "" rendered.append(f'
{body}
') grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));" body = f"""

{_htmlmod.escape(dash['name'])}

{''.join(rendered) if rendered else '

Empty dashboard — add widgets to layout_json.

'}
""" return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body)) @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.""" # v6.0.0: granular collection permissions — hide restricted collections. _require_view(collection_id, _session_user(request)) 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=? AND view_type=? ORDER BY position LIMIT 1", (collection_id, view_type), ).fetchone() if not view: 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 from datetime import 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 ── # Multi-collection dashboards and chart aggregations cap the number of # input rows/groups at 200 (keeps the rendered HTML and export reasonable). CHART_MAX_GROUPS = 200 def _chart_values(pages: list[dict], chart_property: str) -> list[float]: """Numeric values of ``chart_property`` across ``pages`` (cap 200).""" values: list[float] = [] for p in pages[:CHART_MAX_GROUPS]: props = json.loads(p.get("property_values_json", "{}") or "{}") v = props.get(chart_property) if v is None or v == "": continue try: values.append(float(v)) except (ValueError, TypeError): continue return values def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float: """Compute count|sum|avg|min|max over a property (or row count).""" values = _chart_values(pages, chart_property) if aggregate == "count": return float(len(pages[:CHART_MAX_GROUPS])) if not values: return 0.0 if aggregate == "sum": return float(sum(values)) if aggregate == "avg": return float(sum(values) / len(values)) if aggregate == "min": return float(min(values)) if aggregate == "max": return float(max(values)) return 0.0 def _fmt_number(value: float) -> str: if abs(value) >= 1e9: return f"{value / 1e9:.2f}B" if abs(value) >= 1e6: return f"{value / 1e6:.2f}M" if abs(value) >= 1e3: return f"{value / 1e3:.1f}K" if value == int(value): return str(int(value)) return f"{value:.2f}" 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, plus the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate).""" chart_type = config.get("chart_type", "bar") chart_property = config.get("chart_property", "") if chart_type == "number": aggregate = config.get("aggregate", "sum" if chart_property else "count") if aggregate not in ("count", "sum", "avg", "min", "max"): aggregate = "sum" if chart_property else "count" num = _chart_aggregate(pages, chart_property, aggregate) label = config.get("title") or chart_property or collection["name"] return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
{label}
{_fmt_number(num)}
{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s) {' of ' + chart_property if chart_property else ''}
""") labels = [] values = [] for p in pages[:CHART_MAX_GROUPS]: labels.append(str(p.get("title") or "")[:30]) props = json.loads(p.get("property_values_json", "{}") or "{}") val = 0.0 if chart_property: v_raw = props.get(chart_property, 0) try: val = float(v_raw) if v_raw else 0.0 except (ValueError, TypeError): val = 0.0 values.append(val) labels_json = json.dumps(labels) values_json = json.dumps(values) subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries" + (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else "")) return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""

{subtitle}

""") 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.""" # v6.0.0: granular collection permissions — hide restricted collections. _require_view(collection_id, _session_user(request)) 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.""" # v6.0.0: granular collection permissions — viewer/commenter cannot create. _require_view(collection_id, _session_user(request)) _require_edit(collection_id, _session_user(request)) 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", {}) cover_url = body.get("cover_url", "") 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) _validate_meta_keys(conn, collection_id, property_values) apply_auto_properties( _collection_properties(conn, collection_id), property_values, _current_user(request), is_create=True, ) 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, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number, json.dumps(property_values)), ) conn.commit() page_id = cur.lastrowid await fire_event("page.created", { "page_id": page_id, "collection_id": collection_id, "title": title, "icon": icon, "properties": property_values, }) await fire_event("collection.page.created", { "page_id": page_id, "collection_id": collection_id, "title": title, }) return {"id": page_id, "title": title, "status": "created"}