v1.7.0 — View Management:
- PUT /db/views/{id}/config (group_by, card_size, cover, visible_properties)
- POST /db/{id}/views/save-as (save current state as new view)
- GET /db/{id}/views/api (list views)
v1.8.0 — Sub-items & Dependencies:
- parent_id auto-référence: GET/POST /db/{id}/pages/{pid}/sub-items
- Status aggregation: GET .../status-aggregate
- Dependencies: POST .../dependencies, POST .../check-deps (blocking constraint)
- 56/56 tests passent (+7 tests)
- Version 1.6.0 → 1.8.0
1113 lines
42 KiB
Python
1113 lines
42 KiB
Python
"""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"<div class='collections-list'>"
|
|
f"<h2>Collections ({len(collections)})</h2>"
|
|
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
@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"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
|
<style>
|
|
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
|
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
|
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
|
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
|
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
|
</style></head><body>
|
|
<h1>{icon} {title}</h1>
|
|
<div class="view-tabs">
|
|
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
|
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
|
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
|
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
|
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
|
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
|
</div>
|
|
{body}
|
|
</body></html>"""
|
|
|
|
|
|
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"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
|
for p in items
|
|
)
|
|
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
|
|
|
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"""
|
|
<style>
|
|
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
|
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
|
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
|
.cal-day.other-month{{opacity:.35}}
|
|
.cal-day.today{{background:#1a2744}}
|
|
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
|
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
|
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
|
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
|
.cal-nav span{{font-size:16px;font-weight:600}}
|
|
</style>
|
|
<div class="cal-nav">
|
|
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
|
<span>{first.strftime('%B %Y')}</span>
|
|
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
|
</div>
|
|
<div class="calendar">
|
|
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
|
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
|
{cells}
|
|
</div>
|
|
<p class="desc">{len(pages)} pages in collection</p>
|
|
""")
|
|
|
|
|
|
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"<div class='gal-cover' style='background-image:url({url})'></div>"
|
|
break
|
|
|
|
prop_tags = "".join(
|
|
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
|
for v in list(props.values())[:3] if v
|
|
)
|
|
|
|
cards += f"""<div class='gal-card'>
|
|
{cover_html}
|
|
<div class='gal-body'>
|
|
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
|
<div class='gal-props'>{prop_tags}</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
|
<style>
|
|
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
|
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
|
.gal-card:hover{{border-color:#555}}
|
|
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
|
.gal-body{{padding:12px}}
|
|
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
|
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
|
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
|
</style>
|
|
<div class="gallery">{cards}</div>
|
|
<p class="desc">{len(pages)} cards</p>
|
|
""")
|
|
|
|
|
|
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"""<div class='list-item'>
|
|
<span class='list-icon'>{p.get('icon','📄')}</span>
|
|
<div class='list-content'>
|
|
<div class='list-title'>{p['title']}</div>
|
|
<div class='list-preview'>{preview}</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
|
<style>
|
|
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
|
.list-item:hover{{border-color:#444}}
|
|
.list-icon{{font-size:18px;margin-top:1px}}
|
|
.list-content{{flex:1;min-width:0}}
|
|
.list-title{{font-size:14px;font-weight:500}}
|
|
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
</style>
|
|
{items}
|
|
<p class="desc">{len(pages)} items</p>
|
|
""")
|
|
|
|
|
|
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,
|
|
"<p class='desc'>No date data to display timeline.</p>")
|
|
|
|
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"""<div class='tl-row'>
|
|
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
|
<div class='tl-track'>
|
|
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
|
</div>
|
|
</div>"""
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
|
<style>
|
|
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
|
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
|
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
|
</style>
|
|
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
|
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
|
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
|
</div>
|
|
{bars}
|
|
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
|
""")
|
|
|
|
|
|
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"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
|
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
|
|
|
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
|
<style>
|
|
table{{width:100%;border-collapse:collapse}}
|
|
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
|
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
|
tr:hover td{{background:#222}}
|
|
</style>
|
|
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
|
<p class="desc">{len(pages)} rows</p>
|
|
""")
|
|
|
|
|
|
@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"}
|