Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a2d45d46 | ||
|
|
f271ac9b7a | ||
|
|
f006880394 | ||
|
|
81746d9440 | ||
|
|
f53205bc45 | ||
|
|
3706689eca | ||
|
|
7dbaefb381 | ||
|
|
15b6b163f4 | ||
|
|
2e5efcfe07 | ||
|
|
1d1cdbd618 | ||
|
|
894004a1f5 | ||
|
|
6582df3bcb | ||
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 | ||
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd | ||
|
|
13dc8fdaad | ||
|
|
770fdc2b68 | ||
|
|
0bc74ad728 | ||
|
|
adf56a2dd8 | ||
|
|
c0925e511b |
@@ -1,6 +1,10 @@
|
||||
# ── Gitea ──
|
||||
GITEA_URL=https://git.dracodev.net
|
||||
GITEA_TOKEN=change-me
|
||||
# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
|
||||
# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
|
||||
# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
|
||||
# invalide (authentification OAuth impossible).
|
||||
GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
@@ -82,3 +86,15 @@ APP_BASE_URL=http://localhost:8080
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
# ── Web tools de l'agent (v7.46.0) ──
|
||||
# Skills « recherche-marche », « veille-techno », « debug-web ».
|
||||
# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
|
||||
# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
|
||||
# search_code → GitHub public (10 req/min sans jeton, 30 avec)
|
||||
# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
|
||||
# compte mais parsé au HTML) : utilisable, moins fiable.
|
||||
WEB_SEARCH_PROVIDER=exa
|
||||
EXA_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
WEB_FETCH_MAX_CHARS=20000
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
|
||||
+1463
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ docker compose up -d
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| Backend | Python 3.13 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.13-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+297
-8
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -11,6 +11,17 @@ from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valeurs de substitution livrées dans app/config.py : elles sont NON VIDES,
|
||||
# donc un simple `bool(client_id and client_secret)` les considérait comme
|
||||
# configurées et /auth/login redirigeait vers Gitea avec client_id=test-id
|
||||
# (échec d'authentification garanti). Un placeholder == provider non configuré.
|
||||
_PLACEHOLDER_CREDS = {"test-id", "test-secret", "change-me", "changeme", "your-client-id"}
|
||||
|
||||
|
||||
def _real(value: str | None) -> bool:
|
||||
"""True when ``value`` is a real credential (not empty, not a placeholder)."""
|
||||
return bool(value) and value.strip().lower() not in _PLACEHOLDER_CREDS
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
params = {
|
||||
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
return (
|
||||
|
||||
+15
-2
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
# Colonnes de la table ``users`` qui ne doivent JAMAIS quitter le serveur :
|
||||
# le cookie de session est signé (HMAC) mais pas chiffré — son payload est
|
||||
# lisible en base64 par quiconque détient le cookie, et ``/auth/user`` le
|
||||
# renvoyait tel quel au client.
|
||||
_SECRET_USER_FIELDS = ("password_hash",)
|
||||
|
||||
|
||||
def public_user(user_data: dict | None) -> dict | None:
|
||||
"""Return a copy of ``user_data`` stripped of credential material."""
|
||||
if user_data is None:
|
||||
return None
|
||||
return {k: v for k, v in user_data.items() if k not in _SECRET_USER_FIELDS}
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||
@@ -30,7 +43,7 @@ class SessionManager:
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
@@ -93,7 +106,7 @@ class SessionManager:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
|
||||
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
|
||||
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
|
||||
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
|
||||
web_search_provider: str = "exa"
|
||||
exa_api_key: str = ""
|
||||
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
|
||||
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -348,6 +348,16 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
||||
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
||||
# restauration ne le remettait pas → page invisible en Library/Recents/
|
||||
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
||||
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
||||
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Private' "
|
||||
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
+3
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.29.0",
|
||||
version="7.52.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -224,6 +224,8 @@ app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||
app.include_router(workspace.public_router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
|
||||
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSRF_TOKEN
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Lightweight CSRF protection for state-changing requests.
|
||||
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
|
||||
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
|
||||
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
|
||||
return await call_next(request)
|
||||
|
||||
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
@@ -1553,3 +1553,34 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(30, "v7.47.0: My Tasks — mapping des propriétés de base de tâches")
|
||||
def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
"""My Tasks façon Notion : une base ne diffuse ses tâches qu'après
|
||||
conversion explicite, et les trois propriétés requises sont **nommées**
|
||||
(pas devinées d'après leur type).
|
||||
|
||||
``collections.is_task`` existait déjà mais ne mémorisait rien : My Tasks
|
||||
devait deviner « la colonne personne = Assigné à » et n'avait aucun moyen
|
||||
de distinguer deux bases connectées. Trois colonnes nullable REFERENCES
|
||||
fixent le mapping ; suppression de la propriété → `SET NULL`, et la base
|
||||
redevient « non configurée » au lieu de pointer sur du vide.
|
||||
"""
|
||||
cols = columns(conn, "collections")
|
||||
for name, target in (
|
||||
("task_assignee_prop", "collection_properties"),
|
||||
("task_status_prop", "collection_properties"),
|
||||
("task_due_prop", "collection_properties"),
|
||||
):
|
||||
if name in cols:
|
||||
continue
|
||||
conn.execute(
|
||||
f"ALTER TABLE collections ADD COLUMN {name} INTEGER "
|
||||
f"REFERENCES {target}(id) ON DELETE SET NULL"
|
||||
)
|
||||
# Index de lecture : « les bases de tâches de cet utilisateur ».
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_task "
|
||||
"ON collections(is_task, workspace_id)"
|
||||
)
|
||||
|
||||
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
# Delete workspaces owned by this user
|
||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||
for ws in ws_rows:
|
||||
# Les bases du workspace tombent avec lui (sinon : collections
|
||||
# orphelines listées par /db et My Tasks).
|
||||
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
|
||||
@@ -498,6 +498,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/skills/{skill_id}")
|
||||
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
|
||||
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
|
||||
|
||||
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
|
||||
colonnes est figée ici (jamais interpolée depuis la requête).
|
||||
"""
|
||||
_current_user_id(request)
|
||||
fields: dict = {}
|
||||
if "name" in body:
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
fields["name"] = name
|
||||
if "description" in body:
|
||||
fields["description"] = str(body.get("description") or "")
|
||||
if "prompt_template" in body:
|
||||
fields["prompt_template"] = str(body.get("prompt_template") or "")
|
||||
if "allowed_tools" in body:
|
||||
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE agent_skills SET {cols} WHERE id=?",
|
||||
(*fields.values(), skill_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
|
||||
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
|
||||
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
@@ -152,7 +153,7 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
|
||||
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
|
||||
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
|
||||
@router.get("/agents/{agent_id:int}")
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
@router.put("/agents/{agent_id:int}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
@router.delete("/agents/{agent_id:int}")
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
+2
-1
@@ -475,10 +475,11 @@ def logout(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.auth.session import public_user
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
return {"authenticated": True, "user": public_user(user)}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — Board : Kanban Notion-style + multi-vues.
|
||||
|
||||
Découpe A28 : l'ancien `board.py` (2 101 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers/constantes dans
|
||||
`_common`. Ré-exportés (importateurs inchangés) : api.py
|
||||
(STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card),
|
||||
webhooks (_issue_column), dashboard (_sidebar_data,
|
||||
_load_workspace_pages, _load_shared_sidebar_pages, _file_icon),
|
||||
tests (_build_page_tree, _REPO_REF_RE, _unfurl_repo).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
board_views,
|
||||
embed,
|
||||
import_,
|
||||
library,
|
||||
page_api,
|
||||
page_media,
|
||||
page_ops,
|
||||
pages,
|
||||
sharing,
|
||||
sync,
|
||||
synced,
|
||||
wiki,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — ré-exports
|
||||
_REPO_REF_RE,
|
||||
AI_KEYWORD_COLORS,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_block_texts,
|
||||
_build_page_tree,
|
||||
_create_page_from_markdown,
|
||||
_ensure_block_ids,
|
||||
_ensure_page_editable,
|
||||
_extract_ai_keywords,
|
||||
_file_icon,
|
||||
_get_project_properties,
|
||||
_issue_column,
|
||||
_load_children,
|
||||
_load_shared_sidebar_pages,
|
||||
_load_workspace_pages,
|
||||
_local_workspaces_for_user,
|
||||
_map_issue_to_card,
|
||||
_record_version,
|
||||
_sidebar_data,
|
||||
_store_uploaded_file,
|
||||
_unfurl_repo,
|
||||
_upload_root,
|
||||
_ws_id_for,
|
||||
asyncio_get_labels,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
wiki,
|
||||
page_api,
|
||||
library,
|
||||
sharing,
|
||||
synced,
|
||||
board_views,
|
||||
pages,
|
||||
page_media,
|
||||
import_,
|
||||
embed,
|
||||
page_ops,
|
||||
sync,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"AI_KEYWORD_COLORS",
|
||||
"STATUS_COLORS",
|
||||
"STATUS_LABELS",
|
||||
"_REPO_REF_RE",
|
||||
"_apply_filters",
|
||||
"_apply_sorts",
|
||||
"_block_texts",
|
||||
"_build_page_tree",
|
||||
"_create_page_from_markdown",
|
||||
"_ensure_block_ids",
|
||||
"_ensure_page_editable",
|
||||
"_extract_ai_keywords",
|
||||
"_file_icon",
|
||||
"_get_project_properties",
|
||||
"_issue_column",
|
||||
"_load_children",
|
||||
"_load_shared_sidebar_pages",
|
||||
"_load_workspace_pages",
|
||||
"_local_workspaces_for_user",
|
||||
"_map_issue_to_card",
|
||||
"_record_version",
|
||||
"_sidebar_data",
|
||||
"_store_uploaded_file",
|
||||
"_unfurl_repo",
|
||||
"_upload_root",
|
||||
"_ws_id_for",
|
||||
"asyncio_get_labels",
|
||||
]
|
||||
@@ -0,0 +1,882 @@
|
||||
"""FlowDeck — Board : helpers et constantes partagés (A28).
|
||||
|
||||
Les 23 helpers de l'ancien board.py (dont 4 async) + constantes
|
||||
(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) —
|
||||
ré-exportés : api.py, webhooks, dashboard, tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
|
||||
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
|
||||
|
||||
AI_KEYWORD_COLORS = [
|
||||
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
|
||||
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
||||
]
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
|
||||
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
||||
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
||||
|
||||
Allowed to edit a locked page: admins and the user who locked it
|
||||
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
||||
"""
|
||||
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row or not row["is_locked"]:
|
||||
return
|
||||
uid = (user or {}).get("id")
|
||||
is_admin = bool((user or {}).get("is_admin"))
|
||||
if is_admin or (uid and row["locked_by"] == uid):
|
||||
return
|
||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_block_ids(blocks) -> None:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Built-in page templates ship without ids (the editor used to assign them
|
||||
client-side only). Without persisted ids, the realtime layer and the editor
|
||||
disagree on block identity, which duplicated lines / shuffled blocks when
|
||||
editing a template-created page. We now materialize ids at creation time.
|
||||
"""
|
||||
import uuid
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = "b" + uuid.uuid4().hex[:12]
|
||||
if isinstance(b.get("children"), list):
|
||||
_ensure_block_ids(b["children"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
for lbl in issue.get("labels", []):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl["name"]),
|
||||
).fetchone()
|
||||
if row and row["column_name"] in columns:
|
||||
return row["column_name"]
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
title = issue.get("title", "Untitled")
|
||||
status = "todo"
|
||||
if issue.get("state") == "closed":
|
||||
status = "done"
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").lower()
|
||||
if "progress" in name or "doing" in name:
|
||||
status = "progress"
|
||||
elif "done" in name or "complete" in name or "terminé" in name:
|
||||
status = "done"
|
||||
|
||||
assignee = issue.get("assignee", {}) or {}
|
||||
assignee_name = assignee.get("login", "")
|
||||
tag = labels[0].get("name", "") if labels else ""
|
||||
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
||||
if tag_color and not tag_color.startswith("#"):
|
||||
tag_color = f"#{tag_color}"
|
||||
|
||||
icon_map = {
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
icon = emoji
|
||||
break
|
||||
|
||||
# Load custom property values
|
||||
props = {}
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
pvs = conn.execute("""
|
||||
SELECT pp.name, pp.prop_type, pv.value
|
||||
FROM property_values pv
|
||||
JOIN project_properties pp ON pp.id = pv.property_id
|
||||
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
|
||||
""", (owner, repo, issue.get("number", 0))).fetchall()
|
||||
for pv in pvs:
|
||||
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
|
||||
|
||||
# AI keywords from DB
|
||||
keywords = []
|
||||
if owner and repo:
|
||||
with get_conn() as conn:
|
||||
kw_rows = conn.execute(
|
||||
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
# Filter: show keywords matching this issue's labels
|
||||
label_names = {lbl.get("name", "").lower() for lbl in labels}
|
||||
for kw in kw_rows:
|
||||
if kw["keyword"].lower() in label_names or any(
|
||||
kw["keyword"].lower() in lbl for lbl in label_names
|
||||
):
|
||||
keywords.append({"name": kw["keyword"], "color": kw["color"]})
|
||||
|
||||
return {
|
||||
"id": str(issue.get("number", 0)),
|
||||
"title": title,
|
||||
"status": status,
|
||||
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
|
||||
"status_label": STATUS_LABELS.get(status, "To-do"),
|
||||
"icon": icon,
|
||||
"assignee": assignee_name,
|
||||
"tag": tag if tag else None,
|
||||
"tag_color": tag_color,
|
||||
"due_date": issue.get("due_date", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"keywords": keywords,
|
||||
"custom_props": props,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
|
||||
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
|
||||
items.append({
|
||||
"id": f"page/{row['id']}",
|
||||
"db_id": row["id"],
|
||||
"name": row["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{row['id']}",
|
||||
"active": False,
|
||||
"depth": depth,
|
||||
"has_children": len(children) > 0,
|
||||
"children": children,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to icon name (SVG-safe)."""
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
||||
return 'image'
|
||||
if n.endswith('.pdf'):
|
||||
return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n):
|
||||
return 'edit'
|
||||
if n.endswith('.py'):
|
||||
return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.css'):
|
||||
return 'file'
|
||||
if n.endswith('.json'):
|
||||
return 'file'
|
||||
if n.endswith('.sql'):
|
||||
return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.ps1'):
|
||||
return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(txt|log)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
||||
return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace."""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
items.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return items
|
||||
except (ValueError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
children.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return children
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
|
||||
with get_conn() as conn:
|
||||
own_ws = (
|
||||
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
|
||||
)
|
||||
own_ws_names = (
|
||||
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT w.name FROM workspaces w "
|
||||
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
|
||||
)
|
||||
# Scope "shared by me"-style lists to pages in the user's own workspaces
|
||||
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
|
||||
scope_cond = (
|
||||
f"(workspace_id IN ({own_ws}) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) IN "
|
||||
f"(SELECT lower(name) FROM ({own_ws_names}))) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
|
||||
f"(SELECT login FROM users WHERE id=?))))"
|
||||
)
|
||||
scope_params = (user_id, user_id, user_id, user_id, user_id)
|
||||
|
||||
made_nominal = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.created_by=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
made_link = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
made_flag = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
published_rows = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": icon,
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
made_map = {}
|
||||
for r in (*made_nominal, *made_link, *made_flag):
|
||||
made_map.setdefault(r["id"], r)
|
||||
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_made = [_entry(r, "link") for r in made_sorted]
|
||||
received_sorted = [r for r in received_rows if r["id"] not in made_map]
|
||||
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_received = [_entry(r, "users") for r in received_sorted]
|
||||
published = [_entry(r, "globe") for r in published_rows]
|
||||
shared_all = [_entry(r, "link") for r in made_sorted]
|
||||
return shared_made, shared_received, published, shared_all
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
|
||||
|
||||
# Active workspace name from cookie (for local workspace display)
|
||||
from app.routers.dashboard import WORKSPACE_COOKIE
|
||||
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: preserve context across pages. Also load the local
|
||||
# mirror workspace so it appears in "My Workspaces" in the top section
|
||||
# of the sidebar, in parallel with the Gitea repository tree.
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Get local workspace ID for mirror and load its tree
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
|
||||
# l'espace ACTIF (le bouton Home de base.html pointait sinon
|
||||
# sur ``local_workspaces[0]``, premier espace trie par nom).
|
||||
local_ws_id = wsi
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
"Kanban board": "kanban", "Detailed board": "detailed",
|
||||
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
|
||||
}
|
||||
first = True
|
||||
for label, view in view_map.items():
|
||||
indent = 0 if first else 1
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "folder" if first else "",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
})
|
||||
first = False
|
||||
# Load pages as nested tree for this project workspace
|
||||
with get_conn() as conn:
|
||||
tree_pages = _build_page_tree(conn, None, ws_key)
|
||||
for p in tree_pages:
|
||||
recent.append(p)
|
||||
# Private pages: same as recent but filtered for page/ items (non-board views)
|
||||
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
|
||||
|
||||
# Load favorite pages from DB
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
|
||||
"workspace_pages": workspace_pages,
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
"""Extract and persist AI keywords from issue labels and body."""
|
||||
if not owner or not repo:
|
||||
return
|
||||
candidates = set()
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").strip().lower()
|
||||
if name and len(name) > 1:
|
||||
candidates.add(name)
|
||||
# Simple extraction from body: single words > 3 chars
|
||||
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
|
||||
if word not in ("this", "that", "with", "from", "have", "when", "will"):
|
||||
candidates.add(word)
|
||||
|
||||
with get_conn() as conn:
|
||||
for kw in candidates:
|
||||
kw = kw[:30]
|
||||
existing = conn.execute(
|
||||
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
|
||||
(owner, repo, kw),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
|
||||
(existing["usage_count"] + 1, existing["id"]))
|
||||
else:
|
||||
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
|
||||
conn.execute(
|
||||
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
|
||||
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def asyncio_get_labels(owner: str, repo: str):
|
||||
return await gitea.get_labels(owner, repo)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
|
||||
if status_filter:
|
||||
allowed = set(status_filter.split(","))
|
||||
cards = [c for c in cards if c["status"] in allowed]
|
||||
if filters:
|
||||
for f in filters.split(","):
|
||||
if ":" in f:
|
||||
prop, val = f.split(":", 1)
|
||||
val_lower = val.lower()
|
||||
if prop == "assignee":
|
||||
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
|
||||
elif prop == "tag":
|
||||
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
|
||||
elif prop == "keyword":
|
||||
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
|
||||
return cards
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
if not sorts:
|
||||
return cards
|
||||
order = {"todo": 0, "progress": 1, "done": 2}
|
||||
for spec in reversed(sorts.split(",")):
|
||||
if ":" not in spec:
|
||||
continue
|
||||
field, direction = spec.split(":", 1)
|
||||
rev = direction == "desc"
|
||||
if field == "name":
|
||||
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
|
||||
elif field == "status":
|
||||
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
|
||||
elif field == "assignee":
|
||||
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
|
||||
elif field == "deadline":
|
||||
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
|
||||
return cards
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
||||
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
||||
prev = conn.execute(
|
||||
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
||||
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if prev is not None and prev["blocks_json"] == blocks_json:
|
||||
if prev["title"] != (title or ""):
|
||||
conn.execute(
|
||||
"UPDATE page_versions SET title=? WHERE id="
|
||||
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
||||
(title or "", page_id),
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
||||
(page_id, user_id, title or "", blocks_json),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _block_texts(b: dict) -> list[str]:
|
||||
"""Flatten a block (including children) into searchable text chunks."""
|
||||
out = []
|
||||
raw = b.get("content")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append(raw)
|
||||
for child in b.get("children") or []:
|
||||
out.extend(_block_texts(child))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
ws_id = int(cookie)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if row and row["workspace_id"]:
|
||||
return int(row["workspace_id"])
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
||||
{file_url, file_path, mime_type, size, file_name}."""
|
||||
import datetime
|
||||
import re as _re
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
||||
return {
|
||||
"file_url": f"/api/files/{ws_id}/{final}",
|
||||
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
||||
"mime_type": mime,
|
||||
"size": (folder / final).stat().st_size,
|
||||
"file_name": name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
|
||||
|
||||
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
|
||||
"""Convert markdown → blocks (server-side, same mapping as the editor) and
|
||||
create a page in the caller's workspace."""
|
||||
from app.services.export import _md_to_blocks
|
||||
|
||||
blocks = _md_to_blocks(markdown or "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
file_title = title.strip() or "Import"
|
||||
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
|
||||
if not blocks:
|
||||
blocks = [{"type": "paragraph", "content": markdown or ""}]
|
||||
with get_conn() as conn:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
|
||||
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
|
||||
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
async with shared_client(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""FlowDeck — Board : board_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import (
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
_apply_filters,
|
||||
_apply_sorts,
|
||||
_extract_ai_keywords,
|
||||
_get_project_properties,
|
||||
_map_issue_to_card,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
initial_view=view, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ View fragments ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
||||
async def board_view(
|
||||
request: Request, owner: str, repo: str, view: str,
|
||||
status: str = Query(default=""),
|
||||
filter: str = Query(default=""),
|
||||
sort: str = Query(default=""),
|
||||
):
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
|
||||
cards = _apply_filters(cards, status, filter)
|
||||
cards = _apply_sorts(cards, sort)
|
||||
except Exception as e:
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
gid = gname.lower().replace(" ", "-")
|
||||
gcards = cards
|
||||
groups.append({
|
||||
"id": gid, "name": gname,
|
||||
"counts": {
|
||||
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
||||
"done": len([c for c in gcards if c["status"] == "done"]),
|
||||
},
|
||||
"cards": gcards,
|
||||
})
|
||||
|
||||
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
||||
|
||||
template_map = {
|
||||
"table": "table_view.html",
|
||||
"status": "status_overview.html",
|
||||
"teamload": "team_load.html",
|
||||
"detailed": "detailed_board.html",
|
||||
}
|
||||
|
||||
if view == "table":
|
||||
grouped = {g["name"]: g["cards"] for g in groups}
|
||||
ctx["grouped_cards"] = grouped
|
||||
elif view == "status":
|
||||
counts = {"todo": 0, "progress": 0, "done": 0}
|
||||
for c in cards:
|
||||
if c["status"] in counts:
|
||||
counts[c["status"]] += 1
|
||||
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
|
||||
elif view == "teamload":
|
||||
members = {}
|
||||
for c in cards:
|
||||
name = c.get("assignee") or "Unassigned"
|
||||
if name not in members:
|
||||
members[name] = {"name": name, "initial": name[0].upper(),
|
||||
"todo": 0, "progress": 0, "complete": 0, "total": 0}
|
||||
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
||||
members[name][sk] += 1
|
||||
members[name]["total"] += 1
|
||||
ctx["team_data"] = list(members.values())
|
||||
elif view == "detailed":
|
||||
pass
|
||||
else:
|
||||
template_map["kanban"] = "board_fragment.html"
|
||||
|
||||
template_name = template_map.get(view, "board_fragment.html")
|
||||
template = env.get_template(template_name)
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
|
||||
(owner, repo, name, prop_type, opts),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property already exists: {e}") from e
|
||||
return {"status": "ok", "name": name, "type": prop_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
(owner, repo, name),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, f"Property '{name}' not found")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
|
||||
(prop["id"], issue_id, value),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
(owner, repo),
|
||||
).fetchall()
|
||||
return {"keywords": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
|
||||
async def extract_ai_keywords(owner: str, repo: str):
|
||||
"""Re-extract keywords from all issues in the repo."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
for issue in issues:
|
||||
if not issue.get("pull_request"):
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FlowDeck — Board : embed.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from ._common import _REPO_REF_RE, _unfurl_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — Board : import_.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.automations import fire_event
|
||||
|
||||
from ._common import _create_page_from_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import")
|
||||
async def import_page(request: Request):
|
||||
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
markdown = body.get("markdown", "")
|
||||
title = body.get("title", "")
|
||||
if not markdown and not body.get("csv"):
|
||||
raise HTTPException(400, "markdown field required")
|
||||
if not markdown.strip():
|
||||
raise HTTPException(400, "markdown is empty")
|
||||
page_id = await _create_page_from_markdown(request, markdown, title)
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
|
||||
"workspace": ""})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/import/file")
|
||||
async def import_file(request: Request):
|
||||
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
|
||||
markdown pages) into the workspace. Returns the created page ids."""
|
||||
import io as _io
|
||||
import zipfile
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
created_ids = []
|
||||
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(_io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid zip archive") from None
|
||||
md_entries = sorted(
|
||||
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not md_entries:
|
||||
raise HTTPException(400, "No .md files found in archive")
|
||||
for name in md_entries:
|
||||
raw = zf.read(name).decode("utf-8", errors="replace")
|
||||
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
|
||||
try:
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
except Exception as exc: # noqa: BLE001 - keep importing the rest
|
||||
logger.warning("import failed for %s: %s", name, exc)
|
||||
else:
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Only text/markdown files are supported") from None
|
||||
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
|
||||
if not created_ids:
|
||||
raise HTTPException(422, "No pages could be imported")
|
||||
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""FlowDeck — Board : library.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
with get_conn() as conn:
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
page = dict(r)
|
||||
all_pages.append({
|
||||
"id": f"page/{page['id']}",
|
||||
"name": page["title"] or "Untitled",
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
sidebar["recent_pages"] = all_pages
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
sidebar["shared_made_pages"] = []
|
||||
sidebar["shared_received_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
@@ -0,0 +1,229 @@
|
||||
"""FlowDeck — Board : page_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _ensure_block_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
is_admin = 1 if user.get("is_admin") else 0
|
||||
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
||||
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
updates[key] = 1 if body[key] else 0
|
||||
if not updates:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
from app.services.block_templates import template_list
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, icon, description, created_by
|
||||
FROM page_global_templates
|
||||
WHERE created_by IS NULL OR created_by=?
|
||||
ORDER BY created_at""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
mine = [dict(r) for r in rows]
|
||||
for t in mine:
|
||||
t["builtin"] = False
|
||||
return {"templates": template_list() + mine}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
blocks = body.get("blocks")
|
||||
if body.get("page_id"):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
||||
(int(body["page_id"]),)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
if row["content_format"] == "blocks" and row["content"]:
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(400, "Page content is not block JSON") from None
|
||||
if not isinstance(blocks, list) or not blocks:
|
||||
raise HTTPException(400, "blocks (or page_id) required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(name, body.get("icon") or "📄", body.get("description") or "",
|
||||
json.dumps(blocks), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"status": "ok", "id": tid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
key = body.get("key") or "empty"
|
||||
blocks_json = blocks_json_for(key)
|
||||
name = key
|
||||
if blocks_json is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
uid = (user or {}).get("id")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
blocks_json = row["blocks_json"]
|
||||
name = row["name"]
|
||||
title = title or row["name"]
|
||||
# Resolve the target workspace so the new page actually shows up in the
|
||||
# active local workspace (bugfix: template pages previously got
|
||||
# workspace_id = NULL and never appeared in the sidebar/tree).
|
||||
uid = (user or {}).get("id")
|
||||
ws_id_raw = body.get("workspace_id")
|
||||
ws_id = None
|
||||
if ws_id_raw is not None:
|
||||
try:
|
||||
ws_id = int(ws_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
ws_id = None
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
if ws_id is not None:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
||||
ws_key = ws_row["name"] or ws_key
|
||||
else:
|
||||
ws_id = None
|
||||
else:
|
||||
body_ws = (body.get("workspace") or "").strip()
|
||||
if body_ws:
|
||||
ws_key = body_ws
|
||||
# Optional target folder: instantiate the template as a child of it.
|
||||
parent_id = body.get("parent_id")
|
||||
try:
|
||||
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
||||
except (TypeError, ValueError):
|
||||
parent_id = None
|
||||
try:
|
||||
parsed_blocks = json.loads(blocks_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(500, "Template content corrupted") from None
|
||||
_ensure_block_ids(parsed_blocks)
|
||||
blocks_json = json.dumps(parsed_blocks)
|
||||
with get_conn() as conn:
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
||||
(parent_id,),
|
||||
).fetchone()[0]
|
||||
elif ws_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
||||
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Board : page_media.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _store_uploaded_file, _ws_id_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
page = dict(row)
|
||||
|
||||
def copy_tree(src_id: int, parent_id) -> int:
|
||||
with get_conn() as conn:
|
||||
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
||||
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
||||
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
||||
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
||||
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
||||
(parent_id, src_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
for child in conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
||||
).fetchall():
|
||||
copy_tree(child["id"], new_id)
|
||||
return new_id
|
||||
|
||||
new_id = copy_tree(page_id, page.get("parent_id"))
|
||||
title = (page.get("title") or "Untitled") + " copy"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/cover")
|
||||
async def set_page_cover(request: Request, page_id: int):
|
||||
"""v5.5.0: upload an image cover for a page.
|
||||
|
||||
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
||||
an image stored in the workspace's uploads directory.
|
||||
"""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if ctype.startswith("application/json"):
|
||||
body = await request.json()
|
||||
cover_url = (body.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
raise HTTPException(400, "cover_url required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
||||
ws_id = _ws_id_for(request, page_id)
|
||||
meta = await _store_uploaded_file(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "icon": icon}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
@@ -0,0 +1,176 @@
|
||||
"""FlowDeck — Board : page_ops.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
- workspace_id: move page to a different workspace
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
if new_ws_id:
|
||||
# Move to a different workspace: get the workspace name
|
||||
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||
if not ws_row:
|
||||
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_ws_id, ws_row["name"], page_id),
|
||||
)
|
||||
else:
|
||||
# Reorder within same workspace
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_parent_id if new_parent_id > 0 else None, page_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
||||
# even when the stored cache is stale).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Check if page is favorited
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
# Build workspace_id from file_path
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
from app.routers.dashboard import _nav_breadcrumb
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_data": page_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
@@ -0,0 +1,271 @@
|
||||
"""FlowDeck — Board : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _block_texts, _ensure_page_editable, _record_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
next_order = 0
|
||||
parent_val = parent_id if parent_id > 0 else None
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
|
||||
(ws_key, parent_val),
|
||||
).fetchone()
|
||||
if row:
|
||||
next_order = row[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
|
||||
(ws_key, page_title, section, parent_val, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
ids.add(b["synced_id"])
|
||||
if isinstance(b.get("children"), list):
|
||||
ids |= _extract_synced(b["children"])
|
||||
return ids
|
||||
|
||||
synced_ids = _extract_synced(blocks)
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
# Update synced block references
|
||||
existing = {r["synced_block_id"] for r in conn.execute(
|
||||
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
||||
).fetchall()}
|
||||
for sid in synced_ids:
|
||||
if sid not in existing:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
||||
(page_id, sid),
|
||||
)
|
||||
for sid in existing - synced_ids:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
@@ -0,0 +1,236 @@
|
||||
"""FlowDeck — Board : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, 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, run_event_sync
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
collections_hosted_by_pages,
|
||||
delete_collections,
|
||||
)
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
||||
).fetchall()
|
||||
return {"favorites": [r["page_id"] for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||
(mode, 1 if published else 0, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
|
||||
def _trash_session(request: Request):
|
||||
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
||||
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
||||
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/api/trash")
|
||||
def list_trash(request: Request):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||
"path": r["workspace"] or "Private",
|
||||
"deleted_at": r["deleted_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
# La page hôte d'une base disparaît définitivement → sa base et ses
|
||||
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
|
||||
# tâches sans document.
|
||||
collections = collections_hosted_by_page(conn, page_id)
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/empty")
|
||||
def empty_trash(request: Request):
|
||||
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
||||
en racine, comme permanent_delete — comportement historique conservé)."""
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
# Bases portées par les pages purgées (les pages contenu de ligne
|
||||
# n'hébergent aucune base et ne remontent rien).
|
||||
trashed = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
]
|
||||
collections = collections_hosted_by_pages(conn, trashed)
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
@@ -0,0 +1,51 @@
|
||||
"""FlowDeck — Board : sync.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
from ._common import _extract_ai_keywords, _issue_column
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/sync/{owner}/{repo}")
|
||||
async def sync_project(owner: str, repo: str):
|
||||
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
@@ -0,0 +1,165 @@
|
||||
"""FlowDeck — Board : synced.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
"""Session obligatoire — sans garde, un appel anonyme levait
|
||||
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
|
||||
au lieu d'un 401."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _assert_can_edit_block(request: Request, sb: dict) -> None:
|
||||
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
|
||||
user = _session_user(request)
|
||||
if sb.get("created_by") in (None, user.get("id")):
|
||||
return
|
||||
if user.get("is_admin"):
|
||||
return
|
||||
raise HTTPException(403, "Not the author of this synced block")
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
title=body.get("title", "Synced block"),
|
||||
content=body.get("content", []),
|
||||
created_by=user.get("id"),
|
||||
)
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/synced-blocks/{sid}")
|
||||
async def update_synced_block_api(request: Request, sid: int):
|
||||
"""Update a synced block's content (propagates to all pages)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
def get_synced_block_api(request: Request, sid: int):
|
||||
"""Get a synced block by id."""
|
||||
_session_user(request)
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
return dict(sb)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
add_page_synced(page_id, sid, body.get("block_index", 0))
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
@@ -0,0 +1,76 @@
|
||||
"""FlowDeck — Board : wiki.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, title, page_icon, workspace FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 500"""
|
||||
).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
title = r["title"] or "Untitled"
|
||||
if q:
|
||||
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
||||
hay = title.lower()
|
||||
it = iter(hay)
|
||||
subseq = all(ch in it for ch in q)
|
||||
if q not in hay and not subseq:
|
||||
continue
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": title,
|
||||
"icon": r["page_icon"] or "",
|
||||
"workspace": r["workspace"] or "",
|
||||
})
|
||||
if len(results) >= 20:
|
||||
break
|
||||
return {"pages": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
parsed.append(int(part))
|
||||
parsed = parsed[:200]
|
||||
out: dict[str, str] = {}
|
||||
if parsed:
|
||||
placeholders = ",".join("?" * len(parsed))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
||||
parsed,
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["deleted_at"]:
|
||||
out[str(r["id"])] = "Deleted page"
|
||||
else:
|
||||
icon = (r["page_icon"] or "")
|
||||
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
||||
return {"titles": out}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
"""FlowDeck — Collections : bases de données façon Notion.
|
||||
|
||||
Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`
|
||||
(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
|
||||
Ré-exports : automations importe `_validate_page_properties`, les
|
||||
tests importent les helpers de graphes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
boards,
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
index_page,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
task_db,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
_chart_aggregate,
|
||||
_chart_values,
|
||||
_fmt_number,
|
||||
_render_chart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
# `task_db` est enregistré **en premier** : sa route statique
|
||||
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
|
||||
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
|
||||
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
|
||||
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
|
||||
for _mod in (
|
||||
task_db,
|
||||
index_page,
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
meta,
|
||||
properties,
|
||||
views,
|
||||
structure,
|
||||
linked,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"_chart_aggregate",
|
||||
"_chart_values",
|
||||
"_fmt_number",
|
||||
"_render_chart",
|
||||
"_validate_page_properties",
|
||||
]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""FlowDeck — Collections : helpers partagés (A28).
|
||||
|
||||
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
|
||||
validation) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
validate_property_rule,
|
||||
)
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
is_valid_timezone,
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
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()
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""FlowDeck — Collections : rendus HTML des vues (A28).
|
||||
|
||||
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
|
||||
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
|
||||
# ── 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"""<!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_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</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
|
||||
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"<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_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view_type=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>
|
||||
""")
|
||||
|
||||
|
||||
# ── 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).
|
||||
|
||||
|
||||
|
||||
|
||||
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"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
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"""
|
||||
<style>
|
||||
.chart-container{{max-width:800px;margin:0 auto}}
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
}},
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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."""
|
||||
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"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
|
||||
elif ptype in ("select", "status"):
|
||||
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
|
||||
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
|
||||
elif ptype == "checkbox":
|
||||
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
|
||||
elif ptype == "date":
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.form-field{{margin-bottom:12px}}
|
||||
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
|
||||
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
|
||||
.form-submit:hover{{background:#254E99}}
|
||||
</style>
|
||||
<div class="form-container">
|
||||
<h3>New entry in {collection['name']}</h3>
|
||||
<form id="collectionForm" onsubmit="submitForm(event)">
|
||||
{fields}
|
||||
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
|
||||
<button type='submit' class='form-submit'>Submit</button>
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
const fd = new FormData(form);
|
||||
const properties = {{}};
|
||||
const title = fd.get('title') || 'New entry';
|
||||
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
|
||||
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
|
||||
method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||
body: JSON.stringify({{title, properties}})
|
||||
}});
|
||||
if(resp.ok) {{
|
||||
document.getElementById('formResult').style.display='block';
|
||||
form.reset();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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"""
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
|
||||
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
|
||||
</script>
|
||||
<p class="desc">{len(markers)} location(s) mapped</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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"""<div class='feed-item'>
|
||||
<div class='feed-meta'>{created}</div>
|
||||
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
|
||||
<style>
|
||||
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
|
||||
.feed-item:hover{{border-left-color:#3366CC}}
|
||||
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.feed-title{{font-size:14px;font-weight:500}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(sorted_pages)} entries</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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, "<p class='desc'>No date data for Gantt chart.</p>")
|
||||
|
||||
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"<div class='gantt-group'>{group_name} ({len(items)})</div>"
|
||||
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"""<div class='gantt-row'>
|
||||
<span class='gantt-label'>{item['title'][:30]}</span>
|
||||
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
|
||||
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
|
||||
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
|
||||
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
|
||||
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
|
||||
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
|
||||
</style>
|
||||
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
|
||||
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{rows}
|
||||
<p class="desc">{len(gantt_data)} 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>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""FlowDeck — Collections : boards.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
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) ──
|
||||
@@ -0,0 +1,350 @@
|
||||
"""FlowDeck — Collections : crud.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _apply_template, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
#
|
||||
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
|
||||
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
|
||||
# bouton « Ouvrir mes bases » de My Tasks.
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
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")
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
|
||||
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)
|
||||
|
||||
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
|
||||
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
|
||||
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
|
||||
user = _session_user(request)
|
||||
workspace_id = body.get("workspace_id")
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
workspace_id = int(workspace_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
|
||||
with get_conn() as conn:
|
||||
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=400, detail="workspace_id does not exist")
|
||||
else:
|
||||
active = _get_active_workspace(request, (user or {}).get("id"))
|
||||
workspace_id = active["id"] if active else None
|
||||
|
||||
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,
|
||||
workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
|
||||
workspace_id),
|
||||
)
|
||||
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()
|
||||
|
||||
run_event_sync(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) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
|
||||
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()
|
||||
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
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")
|
||||
|
||||
# `delete_collections` détache la page hôte et les vues liées avant la
|
||||
# suppression : `pages.collection_id` et
|
||||
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
|
||||
# suppression brute levait un IntegrityError.
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(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")
|
||||
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 '<original> (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()
|
||||
|
||||
run_event_sync(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) ──
|
||||
@@ -0,0 +1,214 @@
|
||||
"""FlowDeck — Collections : dashboard_views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_view, _session_user
|
||||
from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
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) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
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'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
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)
|
||||
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) ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Collections : data_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
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")
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""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))
|
||||
|
||||
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
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""FlowDeck — Collections : la page « /db » (liste des bases).
|
||||
|
||||
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
|
||||
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
|
||||
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
|
||||
en base de tâches. On le remplace par une vraie page :
|
||||
|
||||
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
|
||||
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
|
||||
sans cela, un utilisateur dont le workspace actif ne contient aucune base
|
||||
tombe sur une page vide alors qu'il en possède ailleurs ;
|
||||
* indicateur « Base de tâches » / « À configurer » par base ;
|
||||
* accès direct à la page de la base, où se trouve le bouton de conversion.
|
||||
|
||||
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
|
||||
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
|
||||
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
|
||||
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
|
||||
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
|
||||
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
|
||||
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
|
||||
les voyait sans pouvoir les ouvrir.
|
||||
|
||||
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
|
||||
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
from ._common import _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
|
||||
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
|
||||
#: `id` ambigus.
|
||||
_CARD_COLUMNS = """
|
||||
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
|
||||
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
|
||||
(SELECT COUNT(*) FROM collection_pages cp
|
||||
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
|
||||
"""
|
||||
|
||||
|
||||
def _esc(value) -> str:
|
||||
return (
|
||||
str(value if value is not None else "")
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """).replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def _card(r: dict) -> str:
|
||||
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
|
||||
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
|
||||
else f"/db/{r['id']}")
|
||||
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
|
||||
missing = [role for role in ("assignee", "status", "due")
|
||||
if not r.get(f"task_{role}_prop")]
|
||||
|
||||
if r["is_task"] and not missing:
|
||||
badge = '<span class="db-card-badge ok">Base de tâches</span>'
|
||||
hint = "Alimente My Tasks."
|
||||
elif r["is_task"]:
|
||||
badge = '<span class="db-card-badge warn">À configurer</span>'
|
||||
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
|
||||
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
|
||||
else:
|
||||
badge = ""
|
||||
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
|
||||
|
||||
# `workspace_id` est NULL pour une base portée par une page : on affiche le
|
||||
# nom du workspace effectif plutôt que celui de la colonne brute.
|
||||
ws_label = r.get("ws_label") or ""
|
||||
meta = f'{r["rows"]} ligne(s)'
|
||||
if ws_label:
|
||||
meta += f" · {ws_label}"
|
||||
|
||||
return f"""<a class="db-card" href="{_esc(target)}">
|
||||
<div class="db-card-head">
|
||||
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
|
||||
<span class="db-card-name">{_esc(r["name"])}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<div class="db-card-meta">{_esc(meta)}</div>
|
||||
<div class="db-card-hint">{_esc(hint)}</div>
|
||||
</a>"""
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Liste des bases du workspace, dans le layout de l'application."""
|
||||
user = _session_user(request)
|
||||
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
with get_conn() as conn:
|
||||
workspace = _get_active_workspace(request, user.get("id")) or {}
|
||||
ws_id = workspace.get("id")
|
||||
ws_name = workspace.get("name") or "Workspace"
|
||||
|
||||
rows = _collections_of(conn, ws_id) if ws_id else []
|
||||
# Bases des autres workspaces : la page ne doit jamais laisser
|
||||
# l'utilisateur sans piste s'il en possède ailleurs.
|
||||
current_ids = {r["id"] for r in rows}
|
||||
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
|
||||
if r["id"] not in current_ids]
|
||||
|
||||
connected = sum(1 for r in rows if r["is_task"])
|
||||
|
||||
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
|
||||
others))
|
||||
|
||||
|
||||
def _collections_of(conn, ws_id) -> list[dict]:
|
||||
"""Bases d'un workspace, vivantes seulement.
|
||||
|
||||
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
|
||||
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
live = set(live_collection_ids(conn, ws_id))
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS}
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
ORDER BY c.name""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows if r["id"] in live]
|
||||
|
||||
|
||||
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
|
||||
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
|
||||
|
||||
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
|
||||
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
|
||||
étant transverse, ces bases l'alimentent aussi).
|
||||
|
||||
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
|
||||
sans ce filtre, la page exposait les bases de tous les utilisateurs de
|
||||
l'instance. Les bases sans workspace rattachable sont exclues — elles
|
||||
n'appartiennent à personne.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
names = {
|
||||
r["id"]: r["name"]
|
||||
for r in conn.execute(
|
||||
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
|
||||
).fetchall()
|
||||
}
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS},
|
||||
{effective_workspace_sql("c")} AS ws_effective
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
ws_ids,
|
||||
).fetchall()
|
||||
|
||||
live: dict[int, set[int]] = {}
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d["ws_effective"]
|
||||
if ws not in live:
|
||||
live[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live[ws]:
|
||||
continue
|
||||
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
|
||||
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
|
||||
has_ws: bool, others: list[dict] | None = None) -> str:
|
||||
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
others = others or []
|
||||
blocks = [_card(r) for r in rows]
|
||||
other_blocks = [_card(r) for r in others]
|
||||
|
||||
if rows or others:
|
||||
total = len(rows) + len(others)
|
||||
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
|
||||
if connected else
|
||||
f"Aucune base de tâches connectée sur {total}")
|
||||
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
|
||||
if other_blocks:
|
||||
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
|
||||
'<p class="db-section-sub">Ces bases appartiennent à '
|
||||
"d'autres workspaces — elles alimentent tout de même "
|
||||
f'My Tasks.</p><div class="db-grid">'
|
||||
f"{''.join(other_blocks)}</div>")
|
||||
body = f"""<div class="db-index">
|
||||
<div class="db-index-head">
|
||||
<h1>📋 Bases de données</h1>
|
||||
<span class="db-index-ws">{_esc(ws_name)}</span>
|
||||
</div>
|
||||
<p class="db-index-sub">{summary} — les bases de tâches alimentent
|
||||
<a href="/my-tasks">My Tasks</a>.</p>
|
||||
{sections}
|
||||
</div>"""
|
||||
elif has_ws:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucune base dans ce workspace</h1>
|
||||
<p>Créez une page, puis convertissez-la en base de données — ou partez
|
||||
d'un modèle depuis le menu d'ajout.</p>
|
||||
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
|
||||
état de configuration.</p>
|
||||
</div>"""
|
||||
else:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucun workspace sélectionné</h1>
|
||||
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
|
||||
</div>"""
|
||||
|
||||
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
|
||||
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
|
||||
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
|
||||
# retiré par htmx lors d'une navigation partielle.
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**_sidebar_data(request, []),
|
||||
request=request,
|
||||
content_html=body,
|
||||
page_title="Bases de données",
|
||||
title_prefix="Bases de données",
|
||||
page_icon="grid",
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
"""FlowDeck — Collections : linked.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
|
||||
from ._common import _apply_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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")
|
||||
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")
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
|
||||
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 ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.4.0: Tasks & Dependencies ──
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks).
|
||||
|
||||
Rétrocompatible : l'activation passe désormais par la même conversion que
|
||||
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
|
||||
(créées si besoin). Sans cela, cette route laissait une base « connectée »
|
||||
mais muette — elle n'émettait aucune tâche faute de mapping.
|
||||
"""
|
||||
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
|
||||
|
||||
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")
|
||||
if coll["is_task"]:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 0
|
||||
else:
|
||||
enable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 1
|
||||
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")
|
||||
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")
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
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")
|
||||
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"}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""FlowDeck — Collections : meta.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
expand_rule,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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(),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""FlowDeck — Collections : pages.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
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")
|
||||
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")
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
|
||||
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()
|
||||
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
}))
|
||||
run_event_sync(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")
|
||||
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()
|
||||
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
}))
|
||||
run_event_sync(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) ──
|
||||
@@ -0,0 +1,322 @@
|
||||
"""FlowDeck — Collections : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""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.
|
||||
"""
|
||||
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")
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
|
||||
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")
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update 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")
|
||||
|
||||
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")
|
||||
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) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
|
||||
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")
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
|
||||
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")
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
|
||||
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")
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
|
||||
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 ──
|
||||
@@ -0,0 +1,267 @@
|
||||
"""FlowDeck — Collections : structure.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
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")
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
|
||||
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
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
}))
|
||||
run_event_sync(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")
|
||||
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")
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
|
||||
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")
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
blocks_ids = props.get("blocks", [])
|
||||
|
||||
if not blocks_ids:
|
||||
return {"can_transition": True, "blocked_by": []}
|
||||
|
||||
# Check blocked pages status
|
||||
placeholders = ",".join("?" for _ in blocks_ids)
|
||||
blocked = conn.execute(
|
||||
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
blocks_ids,
|
||||
).fetchall()
|
||||
|
||||
blockers = []
|
||||
for b in blocked:
|
||||
bprops = json.loads(b["property_values_json"])
|
||||
bstatus = None
|
||||
for v in bprops.values():
|
||||
if isinstance(v, str) and v:
|
||||
bstatus = v
|
||||
break
|
||||
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||
|
||||
return {
|
||||
"can_transition": len(blockers) == 0,
|
||||
"blocked_by": blockers,
|
||||
}
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v4.1.0: Data Sources & Linked Databases ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
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")
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
|
||||
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")
|
||||
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"}
|
||||
@@ -0,0 +1,270 @@
|
||||
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
|
||||
|
||||
Unlike Notion — where any base can be added to the dashboard widget from
|
||||
scratch — a base doit **explicitement** alimenter My Tasks :
|
||||
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
|
||||
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
|
||||
Tant que le mapping est incomplet, la base est connectée mais **n'émet
|
||||
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
|
||||
plutôt que de deviner une colonne.
|
||||
|
||||
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
ROLE_LABELS,
|
||||
ROLE_TYPES,
|
||||
TASK_ROLES,
|
||||
list_task_sources,
|
||||
source_state,
|
||||
)
|
||||
|
||||
from ._common import _require_edit, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
|
||||
DEFAULT_STATUS_OPTIONS = [
|
||||
{"name": "À faire", "color": "gray"},
|
||||
{"name": "En cours", "color": "blue"},
|
||||
{"name": "Terminée", "color": "green"},
|
||||
]
|
||||
|
||||
|
||||
def _user_id(request: Request) -> int:
|
||||
"""ID de l'appelant — session obligatoire.
|
||||
|
||||
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
|
||||
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
|
||||
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
|
||||
"""
|
||||
user = _session_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.get("/task-dbs/api")
|
||||
def list_connected_task_databases(request: Request):
|
||||
"""Bases de tâches connectées au tableau de bord My Tasks."""
|
||||
uid = _user_id(request)
|
||||
with get_conn() as conn:
|
||||
sources = list_task_sources(conn, uid)
|
||||
return {
|
||||
"sources": [
|
||||
{k: s[k] for k in (
|
||||
"id", "name", "icon", "workspace_id", "workspace_name",
|
||||
"configured", "missing_roles", "status_options",
|
||||
)}
|
||||
for s in sources
|
||||
],
|
||||
"total": len(sources),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/task-db/api")
|
||||
def get_task_db_state(request: Request, collection_id: int):
|
||||
"""État de conversion + colonnes candidates pour la modale."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/{collection_id}/task-db/api")
|
||||
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
|
||||
|
||||
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
|
||||
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
|
||||
pour qu'une colonne absente soit créée dans la transaction.
|
||||
"""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
|
||||
with get_conn() as conn:
|
||||
state = enable_task_db_in_conn(
|
||||
conn,
|
||||
collection_id,
|
||||
mapping=body.get("mapping") or {},
|
||||
create_missing=body.get("create_missing") or {},
|
||||
)
|
||||
|
||||
return {"status": "enabled", "collection_id": collection_id,
|
||||
"mapping": state["mapping"], "configured": state["configured"]}
|
||||
|
||||
|
||||
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
|
||||
create_missing: dict | None = None) -> dict:
|
||||
"""Active ``is_task`` et enregistre le mapping — dans la transaction
|
||||
appelante.
|
||||
|
||||
Point d'entrée unique de la conversion : la route `/task-db/api` comme
|
||||
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
|
||||
« connectée » sans aucune colonne liée, donc muette).
|
||||
"""
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# ── Limite de sources (on recompte : la base elle-même peut déjà être
|
||||
# connectée, auquel cas on ne la compte pas deux fois).
|
||||
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
|
||||
"Déconnectez une base avant d'en ajouter une autre."
|
||||
),
|
||||
)
|
||||
|
||||
# ── Création des colonnes manquantes ──
|
||||
create_missing = create_missing or {}
|
||||
mapping = dict(mapping or {})
|
||||
by_name = {p["name"]: p for p in state["all_properties"]}
|
||||
for role in TASK_ROLES:
|
||||
if mapping.get(role):
|
||||
continue
|
||||
spec = create_missing.get(role) or {}
|
||||
name = (spec.get("name") or ROLE_LABELS[role]).strip()
|
||||
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
|
||||
if prop_type not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
|
||||
)
|
||||
if name in by_name:
|
||||
# La colonne existe déjà (nom saisi) → on la lie.
|
||||
mapping[role] = by_name[name]["id"]
|
||||
continue
|
||||
created = _create_property(conn, collection_id, name, prop_type,
|
||||
spec.get("options"))
|
||||
mapping[role] = created["id"]
|
||||
|
||||
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
|
||||
props = {
|
||||
p["id"]: p
|
||||
for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
seen: dict[int, str] = {}
|
||||
for role in TASK_ROLES:
|
||||
prop_id = mapping.get(role)
|
||||
if not prop_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
|
||||
)
|
||||
try:
|
||||
prop_id = int(prop_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Identifiant de propriété invalide") from None
|
||||
prop = props.get(prop_id)
|
||||
if prop is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
|
||||
)
|
||||
if prop["prop_type"] not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"« {prop['name']} » est de type {prop['prop_type']}, "
|
||||
f"incompatible avec « {ROLE_LABELS[role]} »"
|
||||
),
|
||||
)
|
||||
if prop_id in seen:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
|
||||
)
|
||||
seen[prop_id] = ROLE_LABELS[role]
|
||||
mapping[role] = prop_id
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 1,
|
||||
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return source_state(conn, collection_id)
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/task-db/api")
|
||||
def disable_task_db(request: Request, collection_id: int):
|
||||
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
return {"status": "disabled", "collection_id": collection_id}
|
||||
|
||||
|
||||
def disable_task_db_in_conn(conn, collection_id: int) -> None:
|
||||
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
|
||||
pour ne pas laisser des identifiants de colonnes mortes."""
|
||||
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
|
||||
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(collection_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _create_property(conn, collection_id: int, name: str, prop_type: str,
|
||||
options=None) -> dict:
|
||||
"""Crée une propriété (à la position suivante) et la renvoie.
|
||||
|
||||
Une colonne Statut amorcée sans options reçoit le trio classique
|
||||
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
|
||||
aucune colonne à afficher.
|
||||
"""
|
||||
import json
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
|
||||
"WHERE collection_id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
position = row[0] if row else 0
|
||||
|
||||
opts = []
|
||||
for opt in options or []:
|
||||
if isinstance(opt, str):
|
||||
opts.append({"name": opt, "color": "gray"})
|
||||
elif isinstance(opt, dict) and opt.get("name"):
|
||||
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
|
||||
if not opts and prop_type in ("status", "select"):
|
||||
opts = list(DEFAULT_STATUS_OPTIONS)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, position, required,
|
||||
visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 1)""",
|
||||
(collection_id, name, prop_type, json.dumps(opts), position),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": prop_id, "name": name, "prop_type": prop_type}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""FlowDeck — Collections : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
from ._common import _current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
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")
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
|
||||
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")
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
|
||||
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
|
||||
|
||||
run_event_sync(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")
|
||||
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")
|
||||
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")
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
|
||||
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
|
||||
|
||||
run_event_sync(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 ──
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard (pages HTML + API de l'app).
|
||||
|
||||
Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`,
|
||||
re-export de tout ce que les 7 importateurs existants utilisent
|
||||
(main, board, my_tasks, web_clipper, wiki, sites, tests).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique)
|
||||
account_api,
|
||||
account_settings,
|
||||
local_workspace,
|
||||
pages_api,
|
||||
pages_html,
|
||||
public,
|
||||
workspace,
|
||||
workspaces,
|
||||
)
|
||||
from ._common import ( # noqa: F401 — re-export des helpers
|
||||
_VERSION,
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
_format_size,
|
||||
_get_active_workspace,
|
||||
_get_app_version,
|
||||
_get_user_id,
|
||||
_get_user_or_redirect,
|
||||
_local_workspaces_for_user,
|
||||
_nav_breadcrumb,
|
||||
_render_blocks_public,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sanitize_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
pages_html,
|
||||
account_api,
|
||||
workspace,
|
||||
local_workspace,
|
||||
workspaces,
|
||||
account_settings,
|
||||
public,
|
||||
pages_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"WORKSPACE_COOKIE",
|
||||
"_VERSION",
|
||||
"_build_breadcrumb",
|
||||
"_build_tree_children",
|
||||
"_file_page_disk_path",
|
||||
"_format_size",
|
||||
"_get_active_workspace",
|
||||
"_get_app_version",
|
||||
"_get_user_id",
|
||||
"_get_user_or_redirect",
|
||||
"_local_workspaces_for_user",
|
||||
"_nav_breadcrumb",
|
||||
"_render_blocks_public",
|
||||
"_require_page_view",
|
||||
"_require_user_id",
|
||||
"_sanitize_id",
|
||||
"_sidebar_data",
|
||||
]
|
||||
@@ -0,0 +1,780 @@
|
||||
"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28).
|
||||
|
||||
Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état :
|
||||
_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERSION = None
|
||||
|
||||
WORKSPACE_COOKIE = "flowdeck_workspace"
|
||||
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
"""Read version from VERSION file with caching."""
|
||||
global _VERSION
|
||||
if _VERSION is not None:
|
||||
return _VERSION
|
||||
try:
|
||||
import os
|
||||
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
# Docker fallback
|
||||
version_path = "/app/VERSION"
|
||||
if os.path.exists(version_path):
|
||||
with open(version_path) as f:
|
||||
_VERSION = f.read().strip()
|
||||
else:
|
||||
_VERSION = "0.0.0"
|
||||
except Exception:
|
||||
_VERSION = "0.0.0"
|
||||
return _VERSION
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_user_or_redirect(request: Request):
|
||||
"""Return decoded user or a RedirectResponse to login page.
|
||||
Skips redirect when DB has no users (fresh install / test env)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
# Allow through if no users exist yet (fresh install / tests)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws[0].upper() if ws else "B"
|
||||
|
||||
# Get avatar info from DB
|
||||
avatar_url = ""
|
||||
avatar_color = "#3A3A3A"
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
|
||||
if row:
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
full_name = repo.get("full_name", "")
|
||||
recent_pages.append({
|
||||
"id": full_name,
|
||||
"name": repo.get("name", full_name),
|
||||
"icon": "folder",
|
||||
"url": f"/board/{full_name}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Active workspace from cookie (skip on pages like /workspaces where no
|
||||
# workspace context should be shown)
|
||||
from app.routers.board import _load_workspace_pages
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: set owner/repo for client-side tree loading
|
||||
# AND open the local workspace mirror of the same name in the
|
||||
# sidebar's top "My Workspaces" section, in parallel with the
|
||||
# Gitea repository tree.
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Load the local mirror workspace tree so it appears in "My
|
||||
# Workspaces" alongside the Gitea repository section.
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
elif include_workspace and ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify this workspace belongs to the current user
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(wsi, user["id"])
|
||||
).fetchone()
|
||||
if row:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
|
||||
# ne fournissait que son nom et le bouton Home de la sidebar
|
||||
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
|
||||
# PAR NOM) → clic sur Home = changement d'espace surprise.
|
||||
local_ws_id = wsi
|
||||
# else: stale cookie from another user — ignore
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
gitea_linked = False
|
||||
github_linked = False
|
||||
if user and user.get("id"):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if am_row and am_row["auth_method"]:
|
||||
auth_method = am_row["auth_method"]
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_linked = True
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
|
||||
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
|
||||
gitea_mirror_ws_id = 0
|
||||
if gitea_workspace and gitea_owner and gitea_repo:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
gitea_mirror_ws_id = row["id"]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
if gitea_workspace and gitea_mirror_ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
pp_rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
||||
(gitea_mirror_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
shared_received_pages = []
|
||||
published_pages = []
|
||||
shared_pages = []
|
||||
if user and user.get("id"):
|
||||
from app.routers.board import _load_shared_sidebar_pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
|
||||
|
||||
sidebar = {
|
||||
"workspace_name": ws, "workspace_initial": initial,
|
||||
"active_ws_name": active_ws_name,
|
||||
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
|
||||
"gitea_workspace": gitea_workspace,
|
||||
"gitea_owner": gitea_owner,
|
||||
"gitea_repo": gitea_repo,
|
||||
"local_ws_id": local_ws_id,
|
||||
"workspace_pages": workspace_pages,
|
||||
"current_page": "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": private_pages,
|
||||
"favorite_pages": [],
|
||||
"shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"avatar_url": avatar_url,
|
||||
"avatar_color": avatar_color,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
}
|
||||
|
||||
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
|
||||
|
||||
return sidebar
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
|
||||
def _get_user_id(request: Request) -> int:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
|
||||
"""Recursively build the tree of children for a node."""
|
||||
if parent_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
|
||||
# Get workspace owner name for author display
|
||||
ws_owner = conn.execute(
|
||||
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
|
||||
(ws_id,),
|
||||
).fetchone()
|
||||
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
|
||||
|
||||
# Collect all page IDs to fetch tags in one query
|
||||
all_ids = [r["id"] for r in rows]
|
||||
tags_map = {}
|
||||
favorited_ids = set()
|
||||
if all_ids:
|
||||
placeholders = ",".join("?" for _ in all_ids)
|
||||
tag_rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
all_ids,
|
||||
).fetchall()
|
||||
for tr in tag_rows:
|
||||
tags_map.setdefault(tr["page_id"], []).append({
|
||||
"id": tr["id"], "name": tr["name"], "color": tr["color"],
|
||||
})
|
||||
if uid is not None:
|
||||
fav_rows = conn.execute(
|
||||
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
|
||||
[uid, *all_ids],
|
||||
).fetchall()
|
||||
favorited_ids = {fr["page_id"] for fr in fav_rows}
|
||||
|
||||
tree = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
children = _build_tree_children(conn, r["id"], ws_id, uid)
|
||||
|
||||
# Compute size
|
||||
size = 0
|
||||
if r["content_format"] == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(r["content"])
|
||||
size = meta.get("size", 0)
|
||||
except Exception:
|
||||
size = len(r["content"] or "")
|
||||
else:
|
||||
size = len(r["content"] or "")
|
||||
|
||||
tree.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"type": "folder" if is_folder else "page",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"] if not is_folder else None,
|
||||
"page_icon": r["page_icon"] or "",
|
||||
"children": children,
|
||||
"has_children": len(children) > 0,
|
||||
"child_count": len(children),
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"author": author,
|
||||
"tags": tags_map.get(r["id"], []),
|
||||
"is_shared": bool(r["is_shared"]),
|
||||
"favorited": r["id"] in favorited_ids,
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Human-readable file size."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _build_breadcrumb(conn, folder_id: int) -> list:
|
||||
"""Build breadcrumb trail from root to folder_id."""
|
||||
breadcrumb = []
|
||||
current = folder_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row:
|
||||
breadcrumb.insert(0, {
|
||||
"id": row["id"],
|
||||
"name": row["title"] or "Untitled",
|
||||
"is_folder": row["parent_section"] == "Workspace",
|
||||
})
|
||||
current = row["parent_id"]
|
||||
else:
|
||||
break
|
||||
return breadcrumb
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
|
||||
|
||||
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
|
||||
current page (url = None). Every item has ``menu: True`` so the header can
|
||||
open a sibling-navigation dropdown for it.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
chain = []
|
||||
current = page_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section, content_format "
|
||||
"FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
break
|
||||
is_folder = row["parent_section"] == "Workspace"
|
||||
title = row["title"] or "Untitled"
|
||||
chain.insert(0, {
|
||||
"id": row["id"],
|
||||
"label": title,
|
||||
"url": None,
|
||||
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
|
||||
"menu": True,
|
||||
})
|
||||
current = row["parent_id"]
|
||||
# All items except the current page are navigable links.
|
||||
for i, item in enumerate(chain):
|
||||
if i < len(chain) - 1:
|
||||
item["url"] = f"/pages/{item['id']}"
|
||||
return chain
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
|
||||
ws_id = request.cookies.get(WORKSPACE_COOKIE)
|
||||
if ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
|
||||
(int(ws_id),)).fetchone()
|
||||
if ws:
|
||||
ws_dict = dict(ws)
|
||||
# Verify ownership — only return if it belongs to the current user
|
||||
if user_id is None or ws_dict.get("owner_id") == user_id:
|
||||
return ws_dict
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
# Fallback: first workspace owned by this user
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute(
|
||||
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
if ws:
|
||||
return dict(ws)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sanitize_id(block_id: str) -> str:
|
||||
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
|
||||
if not block_id:
|
||||
return ""
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages.
|
||||
|
||||
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
||||
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
||||
"""
|
||||
html_parts = []
|
||||
|
||||
def _wiki(c: str) -> str:
|
||||
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
||||
from app.services.wiki_links import resolve_tokens_html
|
||||
return resolve_tokens_html(c, titles)
|
||||
return c
|
||||
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _wiki(b.get("content", "") or "")
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
||||
elif t == "numbered_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
||||
f'<input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{todo_style}">{c}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children_html = ""
|
||||
if b.get("children"):
|
||||
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
||||
children_html += _render_blocks_public(b["children"], titles)
|
||||
children_html += "</div>"
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
||||
)
|
||||
elif t == "quote":
|
||||
html_parts.append(
|
||||
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
||||
)
|
||||
elif t == "table_of_contents":
|
||||
toc = [
|
||||
x for x in blocks
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
|
||||
]
|
||||
if toc:
|
||||
items = []
|
||||
for h in toc:
|
||||
lvl = int(h["type"].split("_")[-1])
|
||||
items.append(
|
||||
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
|
||||
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
|
||||
)
|
||||
html_parts.append(
|
||||
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
|
||||
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
|
||||
+ "".join(items) + "</div>"
|
||||
)
|
||||
elif t == "math":
|
||||
tex = c.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
html_parts.append(
|
||||
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
|
||||
)
|
||||
elif t == "columns":
|
||||
cols_html = ""
|
||||
for child in b.get("children") or []:
|
||||
cols_html += (
|
||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||
'border-radius:8px;box-sizing:border-box;">'
|
||||
+ _render_blocks_public([child], titles) + "</div>"
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
||||
)
|
||||
elif t == "callout":
|
||||
icon = b.get("icon", "💡")
|
||||
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
||||
f'background:{bg};align-items:flex-start;">'
|
||||
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
||||
f'<span>{c}</span></div>'
|
||||
)
|
||||
elif t == "code":
|
||||
lang = b.get("language", "")
|
||||
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||
html_parts.append(
|
||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
||||
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||
f'{lang_label}'
|
||||
f'<code>{c}</code></pre>'
|
||||
)
|
||||
elif t == "divider":
|
||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||
elif t == "image":
|
||||
src = b.get("src", "")
|
||||
alt = b.get("alt", "")
|
||||
html_parts.append(
|
||||
f'<figure style="margin:16px 0;text-align:center;">'
|
||||
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
|
||||
f'</figure>'
|
||||
)
|
||||
elif t == "video":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
|
||||
f'<source src="{src}"></video>'
|
||||
)
|
||||
elif t == "audio":
|
||||
src = b.get("src", "")
|
||||
if src:
|
||||
html_parts.append(
|
||||
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
|
||||
)
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = b.get("title") or url
|
||||
desc = b.get("description") or ""
|
||||
img = b.get("image") or ""
|
||||
site = b.get("site_name") or ""
|
||||
img_html = (
|
||||
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
)
|
||||
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
html_parts.append(
|
||||
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src", "")
|
||||
emb = b.get("embed_type") or ""
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
html_parts.append('<div>[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
html_parts.append(
|
||||
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
|
||||
)
|
||||
elif emb == "pdf" and url:
|
||||
html_parts.append(
|
||||
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
|
||||
)
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = b.get("height") or 520
|
||||
try:
|
||||
height = int(height)
|
||||
except (ValueError, TypeError):
|
||||
height = 520
|
||||
html_parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
else:
|
||||
inner = b.get("_synced_content")
|
||||
if not isinstance(inner, list) or not inner:
|
||||
try:
|
||||
import json as _sj
|
||||
parsed = _sj.loads(b.get("content") or "[]")
|
||||
inner = parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
inner = []
|
||||
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
||||
for x in inner]
|
||||
if inner:
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""FlowDeck — Dashboard : account_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(uid, token),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": f"fd_{token}"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,439 @@
|
||||
"""FlowDeck — Dashboard : settings.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("settings.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar")
|
||||
async def upload_avatar(request: Request):
|
||||
"""Upload a user avatar image."""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if not file:
|
||||
return {"error": "No file"}, 400
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
# Save to data/avatars
|
||||
avatars_dir = Path("/data/avatars")
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = os.path.splitext(file.filename)[1] or ".png"
|
||||
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filepath = avatars_dir / filename
|
||||
content = await file.read()
|
||||
filepath.write_bytes(content)
|
||||
# Update user avatar_url
|
||||
avatar_url = f"/api/settings/avatar/{filename}"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
|
||||
conn.commit()
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if row and row["avatar_url"]:
|
||||
return RedirectResponse(row["avatar_url"], status_code=302)
|
||||
return JSONResponse({"error": "No avatar"}, status_code=404)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "color": color}
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name or not uid:
|
||||
return {"error": "Tag name required"}, 400
|
||||
with get_conn() as conn:
|
||||
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if tag:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
||||
conn.commit()
|
||||
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
||||
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
||||
conn.commit()
|
||||
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
||||
if "color" in body:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
||||
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
uid = _get_user_id(request)
|
||||
if not ws_id:
|
||||
return {"tags": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, "
|
||||
"(SELECT COUNT(*) FROM page_tags pt "
|
||||
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
" WHERE pt.tag_id = t.id) as count "
|
||||
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
||||
(ws_id, uid),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color FROM tags t "
|
||||
"JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"WHERE pt.page_id = ? ORDER BY t.name",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name:
|
||||
return {"error": "Tag name required"}, 400
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get or create tag (per user)
|
||||
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if not tag:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
||||
)
|
||||
conn.commit()
|
||||
tag_id = cursor.lastrowid
|
||||
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
||||
else:
|
||||
tag_id = tag["id"]
|
||||
|
||||
# Link tag to page (ignore duplicate)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
|
||||
(item_id, tag_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
|
||||
if not tag_names:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
placeholders = ",".join("?" for _ in tag_names)
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
|
||||
f"p.content, p.created_at, p.updated_at "
|
||||
f"FROM pages p "
|
||||
f"JOIN page_tags pt ON pt.page_id = p.id "
|
||||
f"JOIN tags t ON t.id = pt.tag_id "
|
||||
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
|
||||
f"ORDER BY p.updated_at DESC",
|
||||
tag_names + [ws_id],
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
size = len(r["content"] or "")
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"],
|
||||
"created_at": r["created_at"],
|
||||
"updated_at": r["updated_at"],
|
||||
"size": size,
|
||||
"size_display": _format_size(size),
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
||||
if "login" in body:
|
||||
new_login = body["login"].strip()
|
||||
if new_login and new_login != user.get("login"):
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
||||
if existing:
|
||||
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
||||
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
||||
if "email" in body:
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
||||
if "password" in body and body["password"].strip():
|
||||
pw = body["password"].strip()
|
||||
if len(pw) < 6:
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
user_data = dict(row)
|
||||
# Refresh session cookie with updated data (keeps the same session id)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
new_session = SessionManager.refresh_session(cookie, user_data, request)
|
||||
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
||||
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return HTMLResponse("")
|
||||
|
||||
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
if not ws_cookie:
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
||||
|
||||
# Gitea workspace — no server-side tree, loaded client-side
|
||||
if ws_cookie.startswith("gitea:"):
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
||||
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
# Verify workspace belongs to user
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws_id, user["id"])
|
||||
).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
pages = _load_workspace_pages(ws_cookie)
|
||||
if not pages:
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
html = template.render(pages=pages)
|
||||
return HTMLResponse(html)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
@@ -0,0 +1,606 @@
|
||||
"""FlowDeck — Dashboard : local_workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_page_view,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
|
||||
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
|
||||
produit par le sidebar mais **ignoré** par la route : le contenu affiché
|
||||
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
|
||||
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
|
||||
pas à l'utilisateur est ignoré (et le workspace actif conservé).
|
||||
"""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws_row = _get_active_workspace(request, user_id=user["id"])
|
||||
if ws is not None:
|
||||
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
|
||||
# l'utilisateur (même règle de propriété que _get_active_workspace).
|
||||
with get_conn() as conn:
|
||||
requested = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws, user["id"]),
|
||||
).fetchone()
|
||||
if requested is not None:
|
||||
ws_row = dict(requested)
|
||||
# L'URL fait foi : on aligne le cookie pour que le reste de
|
||||
# l'application (sidebar, API tree, breadcrumbs) suive le même
|
||||
# workspace que celui affiché.
|
||||
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
|
||||
max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
ws_id = ws_row["id"] if ws_row else None
|
||||
|
||||
# If no workspace exists for this user, redirect to workspaces page
|
||||
if not ws_id:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
return _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
|
||||
|
||||
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
|
||||
folder: int | None) -> HTMLResponse:
|
||||
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
|
||||
|
||||
``sidebar`` a été calculé **avant** le éventuel changement de workspace
|
||||
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
|
||||
sinon la page afficherait le contenu d'un workspace sous le titre d'un
|
||||
autre.
|
||||
"""
|
||||
ws_id = ws_row["id"]
|
||||
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
|
||||
|
||||
# Build breadcrumb if navigating into a folder
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
if folder and ws_id:
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": ws_name,
|
||||
"active_ws_name": ws_name,
|
||||
"active_workspace_id": ws_id,
|
||||
"current_folder_id": current_folder_id or 0,
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
"breadcrumbs": breadcrumb,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(**ctx),
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
Otherwise returns the full recursive tree from root.
|
||||
"""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"tree": [], "breadcrumb": []}
|
||||
uid = _get_user_id(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
if folder:
|
||||
# Show only this folder's children + build breadcrumb
|
||||
children = _build_tree_children(conn, folder, ws_id, uid)
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
||||
else:
|
||||
# Full tree from root
|
||||
roots = _build_tree_children(conn, None, ws_id, uid)
|
||||
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
fmt = row["content_format"]
|
||||
if fmt == "file":
|
||||
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"breadcrumb": breadcrumb}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
workspace's root pages. Each item includes ``has_children`` so the frontend
|
||||
can render an expandable sub-menu.
|
||||
"""
|
||||
from app.routers.board import _file_icon
|
||||
ws_id = workspace_id
|
||||
if not ws_id:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"items": []}
|
||||
with get_conn() as conn:
|
||||
if parent_id:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
ids = [r["id"] for r in rows]
|
||||
child_counts = {}
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
cc_rows = conn.execute(
|
||||
f"SELECT parent_id, COUNT(*) AS c FROM pages "
|
||||
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
||||
f"GROUP BY parent_id",
|
||||
ids,
|
||||
).fetchall()
|
||||
for cr in cc_rows:
|
||||
child_counts[cr["parent_id"]] = cr["c"]
|
||||
items = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"name": title,
|
||||
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"has_children": child_counts.get(r["id"], 0) > 0,
|
||||
"url": f"/pages/{r['id']}",
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
explicit_ws_id = body.get("workspace_id")
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = explicit_ws_id or (ws["id"] if ws else None)
|
||||
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
|
||||
section = 'Workspace' if item_type == 'folder' else 'Private'
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
|
||||
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
|
||||
(ws_key, ws_id, name, section, parent_id)
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "type": item_type}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
_require_user_id(request)
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
_require_user_id(request)
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
_require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
fp.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not fp.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
mime, _ = mimetypes.guess_type(str(fp))
|
||||
content = fp.read_bytes()
|
||||
from fastapi.responses import Response
|
||||
return Response(content=content, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
_require_user_id(request)
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=? WHERE id=?",
|
||||
(new_parent_id, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload")
|
||||
async def upload_local_workspace_file(request: Request):
|
||||
"""Upload one or more files via drag-and-drop.
|
||||
|
||||
Accepts multipart form with 'files' field (one or multiple files).
|
||||
Optional: 'parent_id' to place files in a specific folder.
|
||||
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
files = form.getlist("files")
|
||||
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
with get_conn() as conn:
|
||||
for f in files:
|
||||
filename = f.filename or "untitled"
|
||||
# Sanitize filename: only keep basename, prevent path traversal
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
safe_name = "untitled"
|
||||
|
||||
# Unique filename on disk
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
||||
parent_id),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/upload-folder")
|
||||
async def upload_local_workspace_folder(request: Request):
|
||||
"""Handle recursive folder upload.
|
||||
|
||||
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
||||
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
||||
- 'files': multipart files (one per file in the structure)
|
||||
- 'parent_id': target folder (optional)
|
||||
|
||||
Creates folders first, then uploads files into their respective folders.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
|
||||
parent_id_raw = form.get("parent_id")
|
||||
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
||||
structure_raw = form.get("structure")
|
||||
|
||||
if not structure_raw:
|
||||
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
||||
|
||||
try:
|
||||
structure = json.loads(structure_raw)
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
created_folders = {} # relative_path -> db_id
|
||||
|
||||
with get_conn() as conn:
|
||||
# Phase 1: Create all folders
|
||||
for item in structure:
|
||||
if item.get("type") != "folder":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
folder_name = path_parts[-1]
|
||||
# Determine parent: parent of this folder in the tree
|
||||
if len(path_parts) == 1:
|
||||
actual_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
actual_parent = created_folders.get(parent_path)
|
||||
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
||||
(ws_id, folder_name, actual_parent),
|
||||
)
|
||||
fid = cursor.lastrowid
|
||||
created_folders[item["path"].strip("/")] = fid
|
||||
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
||||
|
||||
# Phase 2: Upload files into their respective folders
|
||||
for item in structure:
|
||||
if item.get("type") != "file":
|
||||
continue
|
||||
path_parts = item["path"].strip("/").split("/")
|
||||
file_name = path_parts[-1]
|
||||
if len(path_parts) == 1:
|
||||
file_parent = root_parent_id
|
||||
else:
|
||||
parent_path = "/".join(path_parts[:-1])
|
||||
file_parent = created_folders.get(parent_path)
|
||||
|
||||
# Find the matching file in multipart data
|
||||
matched = None
|
||||
for f in form.getlist("files"):
|
||||
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
||||
matched = f
|
||||
break
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
safe_name = Path(file_name).name
|
||||
file_path = upload_dir / safe_name
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
(ws_id, file_path.name,
|
||||
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
||||
file_parent),
|
||||
)
|
||||
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "items": results}
|
||||
|
||||
|
||||
# ═══════════ Workspaces CRUD ═══════════
|
||||
@@ -0,0 +1,249 @@
|
||||
"""FlowDeck — Dashboard : pages_api.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""Soft-delete a page (move to trash).
|
||||
|
||||
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return JSONResponse({"error": "Page not found"}, status_code=404)
|
||||
|
||||
if not db_name:
|
||||
db_name = page["title"] or "New Database"
|
||||
|
||||
# Create the collection
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
|
||||
(db_name, page_id, page["workspace_id"]),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
# Create default "Name" property (text, position 0)
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, position, required, visible_in_views)
|
||||
VALUES (?, 'Name', 'title', 0, 1, 1)""",
|
||||
(collection_id,),
|
||||
)
|
||||
|
||||
# Create default "Table" view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?, 'Table', 'table', ?, 0)""",
|
||||
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
|
||||
)
|
||||
|
||||
# Update the page to be a database page
|
||||
conn.execute(
|
||||
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(collection_id, page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "converted",
|
||||
"collection_id": collection_id,
|
||||
"name": db_name,
|
||||
"view_url": f"/pages/{page_id}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
properties = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
pages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
views = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"collection": dict(coll),
|
||||
"properties": properties,
|
||||
"pages": pages,
|
||||
"views": views,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
||||
|
||||
# Get next position
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
# Load default property values from collection properties
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
default_values = {}
|
||||
for p in props:
|
||||
if p["prop_type"] == "title":
|
||||
default_values[str(p["id"])] = title
|
||||
# Caller-provided values (e.g. board "add card in column X") win.
|
||||
incoming = body.get("properties") or {}
|
||||
if isinstance(incoming, dict):
|
||||
default_values.update(incoming)
|
||||
|
||||
from app.services.property_types import apply_auto_properties
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
||||
apply_auto_properties(props, default_values, user, is_create=True)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
||||
_json.dumps(default_values)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"position": max_pos,
|
||||
"property_values_json": default_values,
|
||||
"status": "created",
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
"""FlowDeck — Dashboard : pages_html.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||
# touché par le trash → source de vérité unique, v7.45.5)
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
if ws_key_ws:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
||||
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
||||
else:
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
|
||||
template = env.get_template("library.html")
|
||||
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
||||
# Gitea workspace context for Repository tab
|
||||
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
||||
sidebar["gitea_workspace_owner"] = owner
|
||||
sidebar["gitea_workspace_repo"] = repo
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time.
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Load sub-pages
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
fav = conn.execute(
|
||||
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
||||
(1, page_id),
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except _json.JSONDecodeError:
|
||||
meta = {}
|
||||
file_path = meta.get("file_path", "").replace("\\", "/")
|
||||
mime_type = meta.get("mime_type", "application/octet-stream")
|
||||
file_size = meta.get("size", 0)
|
||||
fp_parts = file_path.split("/")
|
||||
ws_id = ""
|
||||
for p in fp_parts:
|
||||
if p.startswith("workspace_"):
|
||||
ws_id = p.replace("workspace_", "")
|
||||
break
|
||||
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
||||
from urllib.parse import quote
|
||||
safe_name = quote(filename, safe='')
|
||||
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
|
||||
page_data["file_url"] = file_url
|
||||
page_data["file_mime"] = mime_type
|
||||
page_data["file_size"] = file_size
|
||||
page_data["file_name"] = filename
|
||||
|
||||
# For collection (database) pages, load collection + properties + pages
|
||||
collection_data = None
|
||||
if page.get("content_format") == "collection" and page.get("collection_id"):
|
||||
with get_conn() as conn:
|
||||
col = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
|
||||
).fetchone()
|
||||
if col:
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cpages = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cviews = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
collection_data = {
|
||||
"collection": dict(col),
|
||||
"properties": props,
|
||||
"pages": cpages,
|
||||
"views": cviews,
|
||||
}
|
||||
page_data["collection_id"] = page["collection_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view (aussi en
|
||||
# mode embed : `page_editor_collection.html` + body.embed-mode = le
|
||||
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
|
||||
if page.get("content_format") == "collection":
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
else:
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return HTMLResponse(block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
page_title="Help",
|
||||
title_prefix="Help",
|
||||
page_icon="❓",
|
||||
content_html="""<style>
|
||||
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
||||
.help-hero{text-align:center;margin-bottom:48px;}
|
||||
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
||||
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
||||
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
||||
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
||||
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
||||
.help-card .icon{font-size:20px;}
|
||||
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
||||
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
||||
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
||||
.help-card li::before{content:'• ';color:var(--accent);}
|
||||
.help-section{margin-bottom:48px;}
|
||||
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
<h1>❓ FlowDeck Help</h1>
|
||||
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-grid">
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🚀</span>Getting Started</h3>
|
||||
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
||||
<ul>
|
||||
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
||||
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
||||
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
||||
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
||||
<ul>
|
||||
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
||||
<li>Drag & drop pages in the sidebar to reorganize</li>
|
||||
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
||||
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
||||
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
||||
<ul>
|
||||
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
||||
<li>Browse repo file trees in the sidebar</li>
|
||||
<li>Create & edit files with commit messages</li>
|
||||
<li>Sync labels as tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
||||
<p>Share pages with collaborators or publish them to the web.</p>
|
||||
<ul>
|
||||
<li>Click <b>Share</b> in the page editor top-right</li>
|
||||
<li>Share with specific users or get a public link</li>
|
||||
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
||||
<p>Find all your content in one place with powerful filtering.</p>
|
||||
<ul>
|
||||
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
||||
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
||||
<ul>
|
||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
||||
<li>Edits made offline are queued locally and synced automatically</li>
|
||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>⌨️ Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔐 Authentication</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>📶 Offline mode (PWA)</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
||||
page. If a page with the same title already exists, the offline copy is renamed
|
||||
<i>“Title (copie offline)”</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
||||
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
||||
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
||||
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
||||
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>"""
|
||||
))
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
# Check forge connections
|
||||
gitea_connected = False
|
||||
github_connected = False
|
||||
if user.get("id"):
|
||||
with get_conn() as conn:
|
||||
tokens = conn.execute(
|
||||
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
||||
).fetchall()
|
||||
for t in tokens:
|
||||
if t["provider"] == "gitea":
|
||||
gitea_connected = True
|
||||
elif t["provider"] == "github":
|
||||
github_connected = True
|
||||
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
|
||||
template = env.get_template("settings.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response)
|
||||
|
||||
|
||||
# ═══════════ User API endpoints ═══════════
|
||||
@@ -0,0 +1,78 @@
|
||||
"""FlowDeck — Dashboard : public.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _render_blocks_public
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
|
||||
"FROM pages WHERE publish_slug=? AND is_published=1",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Not Found — FlowDeck</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
||||
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
||||
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
wiki_titles_map = token_labels(conn, page["content"])
|
||||
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
# Plain text / markdown
|
||||
text = page["content"]
|
||||
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
||||
|
||||
template = env.get_template("public_page.html")
|
||||
return template.render(
|
||||
title=page["title"] or "Untitled",
|
||||
content_html=content_html,
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
cover_url=page.get("cover_url", ""),
|
||||
page_icon=page.get("page_icon", ""),
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""FlowDeck — Dashboard : workspace.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
search: str = Query(default=""),
|
||||
show_archived: bool = Query(default=False),
|
||||
):
|
||||
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
# ── Not authenticated → show landing page ──
|
||||
if not user:
|
||||
# Allow through if DB is empty (fresh install)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
# ── Authenticated ──
|
||||
user_id = user.get("id", 1)
|
||||
has_gitea = False
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
tok = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ws_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
if ws_count == 0:
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower()
|
||||
or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception as e:
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
show_archived=show_archived, **sidebar)
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspace.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "Gitea Workspace"
|
||||
# Auto-create local workspace mirror for storing local files
|
||||
local_ws_id = None
|
||||
if ws_key:
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], ws_key, "%gitea_repo%")
|
||||
).fetchone()
|
||||
if existing:
|
||||
local_ws_id = existing["id"]
|
||||
else:
|
||||
c = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
||||
)
|
||||
local_ws_id = c.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(local_ws_id, user["id"], "admin")
|
||||
)
|
||||
conn.commit()
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # always true on this page
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
|
||||
}
|
||||
template = env.get_template("gitea_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
async def list_workspace_projects(request: Request):
|
||||
"""List all projects: built-in + Gitea + GitHub.
|
||||
Uses the user's own Gitea token if connected, not the global admin token."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
builtin = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
user_gitea = get_user_gitea_client(request) if user else None
|
||||
if user_gitea:
|
||||
try:
|
||||
repos = await user_gitea.get_user_repos(page=1, limit=50)
|
||||
for repo in repos:
|
||||
gitea_repos.append({
|
||||
"id": str(repo.get("id", "")),
|
||||
"name": repo.get("name", ""),
|
||||
"full_name": repo.get("full_name", ""),
|
||||
"description": repo.get("description", ""),
|
||||
"html_url": repo.get("html_url", ""),
|
||||
"language": repo.get("language", ""),
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
|
||||
(name,),
|
||||
)
|
||||
conn.commit()
|
||||
pid = cursor.lastrowid
|
||||
return {"id": pid, "name": name, "forge": "builtin"}
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
|
||||
|
||||
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
|
||||
FROM workspace_members wm JOIN users u ON u.id = wm.user_id
|
||||
WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
|
||||
).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}, 400
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(ws_id, user["id"], role),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
return {"error": "Already a member"}, 409
|
||||
return {"status": "ok", "user_id": user["id"], "role": role}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
||||
(role, ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
@@ -0,0 +1,167 @@
|
||||
"""FlowDeck — Dashboard : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _require_ws_owner(request: Request, ws_id: int) -> int:
|
||||
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
|
||||
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
|
||||
supprimait des espaces au nom de l'utilisateur 1."""
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
is_admin = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (uid,)
|
||||
).fetchone()
|
||||
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
|
||||
raise HTTPException(403, "Not your workspace")
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
||||
template = env.get_template("workspaces.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
workspaces = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
workspaces.append(d)
|
||||
active = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Ensure user exists (FK constraint)
|
||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not uid_ok:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
|
||||
(uid, user.get("login", "admin") if user else "admin",
|
||||
user.get("full_name", "Admin") if user else "Admin"),
|
||||
)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(name, uid),
|
||||
)
|
||||
ws_id = cursor.lastrowid
|
||||
# Add owner as member
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
||||
(ws_id, uid),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": ws_id, "name": name}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace, its pages and its databases."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
# Les bases du workspace partaient avec ses pages : les laisser créait
|
||||
# des collections orphelines (workspace_id pointant sur rien) que
|
||||
# /db et My Tasks continuaient de lister.
|
||||
collections = workspace_collection_ids(conn, ws_id)
|
||||
delete_collections(conn, collections)
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
|
||||
# ═══════════ Settings Page ═══════════
|
||||
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -115,6 +117,72 @@ def freebusy(collection_id: int, request: Request):
|
||||
return out
|
||||
|
||||
|
||||
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/meetings/upcoming")
|
||||
def upcoming_meetings(request: Request, days: int = 30):
|
||||
"""Upcoming calendar events for the sidebar Meeting tab.
|
||||
|
||||
Reads every calendar collection linked to the current user, extracts the
|
||||
date property of each linked row and returns the ones falling in the next
|
||||
``days`` days (today included), sorted chronologically. Rows imported from
|
||||
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
|
||||
"""
|
||||
user = _auth_user(request)
|
||||
horizon = max(1, min(days, 365))
|
||||
today = datetime.now(UTC).date()
|
||||
end = today + timedelta(days=horizon)
|
||||
events: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
links = conn.execute(
|
||||
"SELECT id, collection_id, date_property, calendar_id, provider "
|
||||
"FROM calendar_links WHERE user_id=? ORDER BY id",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
for link in links:
|
||||
collection_id = link["collection_id"]
|
||||
if not collection_id:
|
||||
continue
|
||||
date_prop = cal.date_prop_id(conn, collection_id,
|
||||
link["date_property"] or "")
|
||||
if not date_prop:
|
||||
continue
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, external_event_id "
|
||||
"FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
raw = cal.row_date(values, prop_id, prop_name)[:10]
|
||||
if len(raw) != 10:
|
||||
continue
|
||||
try:
|
||||
day = date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (today <= day <= end):
|
||||
continue
|
||||
events.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"date": raw,
|
||||
"today": day == today,
|
||||
"collection_id": collection_id,
|
||||
"calendar_id": link["calendar_id"] or "primary",
|
||||
"provider": link["provider"],
|
||||
"synced": bool(row["external_event_id"]),
|
||||
"url": f"/db/{collection_id}",
|
||||
})
|
||||
events.sort(key=lambda e: (e["date"], e["title"].lower()))
|
||||
return {"today": today.isoformat(), "days": horizon,
|
||||
"events": events[:200], "count": len(events)}
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
|
||||
+374
-98
@@ -1,147 +1,423 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
|
||||
|
||||
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
|
||||
rattaché à une page, cette page est un **tableau de bord transverse** : elle
|
||||
réunit toutes les tâches qui vous sont assignées, **tous workspaces
|
||||
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
|
||||
reste utilisé par la sidebar).
|
||||
|
||||
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
|
||||
explicite (`collections.is_task` + mapping des trois propriétés requises —
|
||||
`app/services/task_databases.py`).
|
||||
|
||||
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
|
||||
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
|
||||
en direct du statut et de l'échéance, et création rapide dans n'importe
|
||||
quelle base connectée.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.property_types import apply_auto_properties
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
collect_tasks,
|
||||
filter_and_sort,
|
||||
list_task_sources,
|
||||
user_today,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
VIEWS = ("table", "board", "calendar")
|
||||
DUE_FILTERS = ("all", "today", "overdue", "week")
|
||||
SORTS = ("due", "status", "source", "created")
|
||||
|
||||
|
||||
# ── Session ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
def _require_user(request: Request) -> dict:
|
||||
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||
est strictement celui de l'appelant."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
# ── Données ──────────────────────────────────────────────────────────────
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
due_date = props.get("due_date", "")
|
||||
status = props.get("status", "—")
|
||||
assignee = props.get("assignee", "")
|
||||
if view != "all" and assignee and assignee != user_login:
|
||||
continue
|
||||
collection_tasks.append({
|
||||
"id": p["id"],
|
||||
"title": p["title"] or "Untitled",
|
||||
"icon": p["icon"] or "📋",
|
||||
"status": status,
|
||||
"due_date": due_date,
|
||||
})
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||
).fetchall()
|
||||
return {r["id"]: r["name"] for r in rows}
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
||||
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||
return (
|
||||
view if view in VIEWS else "table",
|
||||
due if due in DUE_FILTERS else "all",
|
||||
sort if sort in SORTS else "due",
|
||||
)
|
||||
|
||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
||||
|
||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||
</div>
|
||||
<style>
|
||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||
.mt-section{{margin-bottom:24px;}}
|
||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||
.mt-item:hover{{border-color:var(--accent);}}
|
||||
.mt-icon{{font-size:16px;}}
|
||||
.mt-title{{flex:1;font-size:14px;}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||
</style>
|
||||
{sections}
|
||||
</div>"""
|
||||
def _public_source(s: dict) -> dict:
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"icon": s["icon"],
|
||||
"workspace_id": s["workspace_id"],
|
||||
"workspace_name": s.get("workspace_name") or "",
|
||||
"configured": s["configured"],
|
||||
"missing_roles": s["missing_roles"],
|
||||
"status_options": s["status_options"],
|
||||
}
|
||||
|
||||
|
||||
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
|
||||
hide_done: bool) -> dict:
|
||||
"""Charge et normalise tout ce dont les trois vues ont besoin."""
|
||||
user = _get_current_user(request) or {}
|
||||
tasks = collect_tasks(conn, user_id, mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
|
||||
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
|
||||
today=user_today(conn, user_id))
|
||||
sources = list_task_sources(conn, user_id)
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"sources": [_public_source(s) for s in sources],
|
||||
"sources_total": len(sources),
|
||||
"sources_configured": sum(1 for s in sources if s["configured"]),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
|
||||
}
|
||||
|
||||
|
||||
# ── Rendu HTML ───────────────────────────────────────────────────────────
|
||||
|
||||
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
|
||||
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
|
||||
|
||||
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
|
||||
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
|
||||
d'une navigation partielle, et la page revenait sans aucun style. Seul
|
||||
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
|
||||
connectée, il n'y a rien à monter côté client.
|
||||
"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = ENV
|
||||
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||
# n'est pas substitutionné si on le concatène à la main.
|
||||
assets = ENV.from_string(
|
||||
"{% if app_js %}"
|
||||
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
|
||||
"{% endif %}"
|
||||
).render(app_js=app_js)
|
||||
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
assets=assets,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
def _json_attr(obj) -> str:
|
||||
"""JSON échappé pour un attribut HTML."""
|
||||
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
|
||||
|
||||
|
||||
def _render_app(data: dict, view: str) -> str:
|
||||
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
|
||||
sont rendus côté client à partir de la configuration ci-dessous."""
|
||||
config = {
|
||||
"view": view,
|
||||
"filters": data["filters"],
|
||||
"sources": data["sources"],
|
||||
"maxSources": data["max_sources"],
|
||||
"views": list(VIEWS),
|
||||
}
|
||||
return ('<div id="my-tasks-app" class="my-tasks-app" '
|
||||
f'data-config="{_json_attr(config)}"></div>')
|
||||
|
||||
|
||||
def _render_empty(data: dict) -> str:
|
||||
"""État vide « aucune source » : explique la conversion en base de tâches.
|
||||
|
||||
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
|
||||
style inline : la mise en page suit le thème clair/sombre et reste
|
||||
modifiable en un seul endroit. Les autres états vides (filtres actifs,
|
||||
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
|
||||
"""
|
||||
return f"""<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
|
||||
<ol class="my-tasks-empty-steps">
|
||||
<li><span class="n">1</span><span>Ouvrez une base de données depuis
|
||||
<a href="/db">la liste de vos bases</a>.</span></li>
|
||||
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
|
||||
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
|
||||
<b>New</b>.</span></li>
|
||||
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
|
||||
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
|
||||
</ol>
|
||||
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
|
||||
peuvent alimenter ce tableau de bord.</p>
|
||||
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _render_no_workspace() -> str:
|
||||
return """<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
|
||||
alimentera ce tableau de bord.</p>
|
||||
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
if not _workspace_names(conn, user["id"]):
|
||||
return _render_shell(request, _render_no_workspace(), app_js=False)
|
||||
data = _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
|
||||
# en charge par le client (3 vues + états vides contextualisés). Sans
|
||||
# aucune source, le serveur affiche l'invite à convertir une base — le
|
||||
# fichier JS n'est alors pas chargé.
|
||||
if data["total"] or data["sources_total"]:
|
||||
return _render_shell(request, _render_app(data, view))
|
||||
return _render_shell(request, _render_empty(data), app_js=False)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
|
||||
user = _require_user(request)
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
return _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
|
||||
@router.post("/api/task")
|
||||
def my_tasks_create(request: Request, body: dict = Body(default={})):
|
||||
"""Création rapide multi-destinations : la tâche part dans la base choisie.
|
||||
|
||||
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
|
||||
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
|
||||
illisible pour l'utilisateur qui vient de la créer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
collection_id = body.get("collection_id")
|
||||
title = str(body.get("title") or "").strip()
|
||||
if not collection_id:
|
||||
raise HTTPException(status_code=400, detail="collection_id is required")
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
coll = conn.execute(
|
||||
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
|
||||
"WHERE id=? AND is_task=1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database does not feed My Tasks")
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
props_meta = [
|
||||
dict(p) for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
properties: dict = {}
|
||||
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||
if title_prop:
|
||||
properties[str(title_prop["id"])] = title
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
if body.get("due") and coll["task_due_prop"]:
|
||||
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
if coll["task_assignee_prop"]:
|
||||
member = conn.execute(
|
||||
"SELECT id, login, full_name, avatar_url, avatar_color "
|
||||
"FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if member:
|
||||
properties[str(coll["task_assignee_prop"])] = [{
|
||||
"id": member["id"],
|
||||
"login": member["login"],
|
||||
"full_name": member["full_name"],
|
||||
"avatar_url": member["avatar_url"] or "",
|
||||
"avatar_color": member["avatar_color"] or "",
|
||||
}]
|
||||
|
||||
apply_auto_properties(props_meta, properties, user, is_create=True)
|
||||
|
||||
nxt = 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, property_values_json)
|
||||
VALUES (?, ?, '📄', ?, ?)""",
|
||||
(collection_id, title, nxt, json.dumps(properties)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
if title_prop:
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
|
||||
return {"status": "created", "id": page_id, "collection_id": collection_id,
|
||||
"title": title}
|
||||
|
||||
|
||||
@router.put("/api/task/{page_id}")
|
||||
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Édition en direct du statut / de l'échéance / du titre.
|
||||
|
||||
L'écriture passe par le **mapping enregistré de la base source** (et non
|
||||
par une devinette « première colonne de type X »), puis la tâche est relue
|
||||
et renvoyée normalisée : le front n'a rien à recalculer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
field = body.get("field")
|
||||
if field not in ("status", "due", "title", "assignee"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported field")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
if not coll["is_task"]:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database no longer feeds My Tasks")
|
||||
|
||||
column = {"status": coll["task_status_prop"],
|
||||
"due": coll["task_due_prop"],
|
||||
"assignee": coll["task_assignee_prop"]}.get(field)
|
||||
if field != "title" and not column:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"This database has no {field} column")
|
||||
|
||||
try:
|
||||
pv = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
if not isinstance(pv, dict):
|
||||
pv = {}
|
||||
|
||||
new_title = None
|
||||
if field == "title":
|
||||
new_title = str(body.get("value") or "").strip()
|
||||
title_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
|
||||
(coll["id"],),
|
||||
).fetchone()
|
||||
if title_prop:
|
||||
pv[str(title_prop["id"])] = new_title
|
||||
elif field == "assignee":
|
||||
# La valeur est une liste de personnes : elle est écrite telle
|
||||
# quelle (booleenner écraserait la colonne).
|
||||
pv[str(column)] = body.get("value") or []
|
||||
elif field == "due":
|
||||
pv[str(column)] = str(body.get("value") or "")[:10]
|
||||
else:
|
||||
pv[str(column)] = str(body.get("value") or "")
|
||||
|
||||
if new_title is not None:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title=?, property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_title, json.dumps(pv), page_id),
|
||||
)
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(pv), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tasks = collect_tasks(conn, user["id"], mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
updated = next((t for t in tasks if t["id"] == page_id), None)
|
||||
|
||||
if updated is None:
|
||||
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
|
||||
# doit la retirer, pas la re-rendre à l'identique.
|
||||
return {"status": "updated", "task": None, "left_mytasks": True}
|
||||
|
||||
return {"status": "updated", "task": updated, "left_mytasks": False}
|
||||
|
||||
+50
-19
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -16,14 +16,32 @@ from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
|
||||
|
||||
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
|
||||
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
|
||||
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
|
||||
La seule route publique (`/workspace/public/...`) vit désormais dans
|
||||
``public_router``, sans dépendance.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
|
||||
# Route de partage public : AUCUNE dépendance d'authentification.
|
||||
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
|
||||
return _require_session(request)
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
"""List the caller's favorites.
|
||||
|
||||
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
|
||||
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
|
||||
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
|
||||
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
|
||||
"""
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||
"""SELECT f.*, p.title AS page_title
|
||||
FROM favorites f
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
LEFT JOIN pages p ON f.page_id=p.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
page_id = body.get("page_id")
|
||||
collection_id = body.get("collection_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
|
||||
(uid, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
user = _require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Favorite not found")
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
@public_router.get("/public/{collection_id}")
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
|
||||
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
# Public aliases — read-only helpers reused by the router layer (sidebar
|
||||
# "Meeting" tab needs the date property + value of a collection row).
|
||||
date_prop_id = _date_prop_id
|
||||
row_date = _row_date
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""FlowDeck — cohérence collections ↔ workspace.
|
||||
|
||||
Une database (`collections`) peut être :
|
||||
|
||||
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
|
||||
* **de page** — page convertie en base (`collections.parent_page_id` +
|
||||
`pages.collection_id`) ou base inline insérée dans une page.
|
||||
|
||||
Ce module centralise les deux sens de la relation :
|
||||
|
||||
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
|
||||
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
|
||||
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
|
||||
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
|
||||
d'un workspace dont tous les documents ont été supprimés ;
|
||||
* **écriture** — la suppression en cascade d'une collection. L'ordre des
|
||||
écritures est dicté par le schéma SQLite :
|
||||
|
||||
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
|
||||
la page hôte doit être détachée **avant** la suppression ;
|
||||
- ``collection_data_sources.source_collection_id`` référence également en
|
||||
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
|
||||
être détachées ;
|
||||
- ``collection_pages`` (lignes), ``collection_properties``,
|
||||
``collection_views``, ``collection_permissions`` et les pages contenu
|
||||
(``pages.collection_row_id``) partent en ``CASCADE``.
|
||||
|
||||
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
|
||||
dès qu'une page hôte (ou une vue liée) existe encore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Lecture ──────────────────────────────────────────────────────────────
|
||||
|
||||
#: SQL du workspace **effectif** d'une collection.
|
||||
#:
|
||||
#: ``collections.workspace_id`` est renseigné pour les databases créées via
|
||||
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
|
||||
#: convertie en base, base inline insérée dans un document) : son workspace
|
||||
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
|
||||
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
|
||||
#: ``workspaces`` les exclut définitivement.
|
||||
EFFECTIVE_WORKSPACE_SQL = (
|
||||
"COALESCE({a}.workspace_id,"
|
||||
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
|
||||
)
|
||||
|
||||
|
||||
def effective_workspace_sql(alias: str = "c") -> str:
|
||||
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
||||
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
||||
|
||||
|
||||
def user_workspace_ids(conn, user_id: int) -> list[int]:
|
||||
"""IDs des workspaces accessibles à l'utilisateur.
|
||||
|
||||
Propriétaire **ou** membre : c'est le même périmètre que la recherche
|
||||
globale (`app/services/search._scope_where`), à la différence près qu'on
|
||||
n'inclut pas les éléments sans workspace — une base non rattachable
|
||||
n'appartient à personne et ne doit donc apparaître chez personne.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id = ?"
|
||||
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
|
||||
(user_id, user_id),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def live_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs des collections rattachées au workspace qui sont encore vivantes.
|
||||
|
||||
Une collection est exclue dès que sa page hôte a disparu :
|
||||
|
||||
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
|
||||
* une page ``content_format='collection'`` qui pointe encore vers elle
|
||||
mais est à la corbeille.
|
||||
|
||||
Les collections sans page hôte (databases de workspace créées via
|
||||
``/db/api``) restent toujours visibles.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT c.id
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
AND (c.parent_page_id IS NULL
|
||||
OR EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY c.name
|
||||
""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_row_ids(conn, collection_id: int) -> list[int]:
|
||||
"""IDs des lignes racines d'une collection dont la page contenu vit.
|
||||
|
||||
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
|
||||
: son absence ne dit rien, mais son passage à la corbeille signifie que
|
||||
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT cp.id
|
||||
FROM collection_pages cp
|
||||
WHERE cp.collection_id = ?
|
||||
AND cp.parent_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY cp.position
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_document_count(conn, workspace_id: int) -> int:
|
||||
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
|
||||
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) FROM pages "
|
||||
"WHERE workspace_id = ? AND parent_id IS NULL "
|
||||
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||
(workspace_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
|
||||
"""IDs des collections dont l'une de ces pages est la page hôte.
|
||||
|
||||
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
|
||||
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
|
||||
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
|
||||
aucune collection et ne remontent donc rien.
|
||||
|
||||
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
|
||||
relation ``pages.collection_id``.
|
||||
"""
|
||||
ids = [int(p) for p in page_ids if p is not None]
|
||||
if not ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id FROM collections WHERE parent_page_id IN ({qs})
|
||||
UNION
|
||||
SELECT collection_id FROM pages
|
||||
WHERE id IN ({qs}) AND collection_id IS NOT NULL
|
||||
""",
|
||||
[*ids, *ids],
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
|
||||
"""``collections_hosted_by_pages`` pour une seule page."""
|
||||
return collections_hosted_by_pages(conn, [page_id])
|
||||
|
||||
|
||||
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs de **toutes** les collections du workspace (sans filtre de
|
||||
vivacité) — utilisé lors de la suppression du workspace."""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
# ── Écriture ─────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_collections(conn, collection_ids) -> int:
|
||||
"""Supprime définitivement des collections et tout ce qu'elles portent.
|
||||
|
||||
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
|
||||
vues liées) puis supprime : le reste part en ``CASCADE``.
|
||||
|
||||
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
|
||||
transaction. Retourne le nombre de collections supprimées.
|
||||
"""
|
||||
ids = [int(i) for i in collection_ids if i is not None]
|
||||
if not ids:
|
||||
return 0
|
||||
qs = ",".join("?" * len(ids))
|
||||
|
||||
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
|
||||
# contenu EST la base redevient une page blocs normale.
|
||||
conn.execute(
|
||||
f"""UPDATE pages
|
||||
SET collection_id = NULL,
|
||||
content_format = CASE WHEN content_format = 'collection'
|
||||
THEN 'blocks' ELSE content_format END
|
||||
WHERE collection_id IN ({qs})""",
|
||||
ids,
|
||||
)
|
||||
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
|
||||
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
|
||||
conn.execute(
|
||||
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
|
||||
ids,
|
||||
)
|
||||
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
|
||||
# suivent en CASCADE.
|
||||
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
|
||||
"""Supprime les databases portées par des pages définitivement supprimées
|
||||
(corbeille purgée, suppression définitive, workspace effacé)."""
|
||||
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
|
||||
@@ -118,7 +118,7 @@ class ContextBuilder:
|
||||
return "\n".join(lines)
|
||||
|
||||
def _mentions_context(self, mentions: list[str]) -> str:
|
||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
||||
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
|
||||
|
||||
Mentions bring the *actual content* of the referenced object into the
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("folder:"):
|
||||
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
return head
|
||||
|
||||
def _single_folder(self, fid: str) -> str:
|
||||
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
|
||||
|
||||
ponytail : enfants directs seulement, budget ~12k caractères —
|
||||
ajouter une profondeur récursive si les arbres profonds deviennent réels.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(fid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"- dossier #{fid}: introuvable"
|
||||
kids = conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(fid,),
|
||||
).fetchall()
|
||||
title = row["title"] or "Sans titre"
|
||||
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
|
||||
out = [head]
|
||||
used = len(head)
|
||||
for k in kids:
|
||||
part = self._single_document(str(k["id"]))
|
||||
if used + len(part) + 1 > 12000:
|
||||
out.append("- … (contenu du dossier tronqué)")
|
||||
break
|
||||
used += len(part) + 1
|
||||
out.append(part)
|
||||
return "\n".join(out)
|
||||
|
||||
def _single_collection(self, cid: str) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
|
||||
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
|
||||
|
||||
"veille-documentaire": {
|
||||
"name": "Veille documentaire",
|
||||
"icon": "🔎",
|
||||
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
|
||||
"prompt_template": (
|
||||
"Fais une veille documentaire du workspace :\n"
|
||||
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
|
||||
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
|
||||
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
|
||||
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
|
||||
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
|
||||
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
|
||||
"Cohérences (points de vue contradictoires entre documents).\n"
|
||||
"4. Ne modifie aucun document : tu proposes, l'humain décide."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"chasse-decisions": {
|
||||
"name": "Chasse aux décisions",
|
||||
"icon": "⚖️",
|
||||
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
|
||||
"prompt_template": (
|
||||
"Construis le registre des décisions du workspace :\n"
|
||||
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
|
||||
"« choix de », « remplacement de »).\n"
|
||||
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
|
||||
"les options écartées.\n"
|
||||
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
|
||||
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
|
||||
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
|
||||
"plutôt que de combler les trous."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"onboarding-nouveau": {
|
||||
"name": "Onboarding nouveau",
|
||||
"icon": "🧭",
|
||||
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
|
||||
"prompt_template": (
|
||||
"Prépare l'onboarding d'une nouvelle personne :\n"
|
||||
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
|
||||
"glossaire (search_workspace).\n"
|
||||
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
|
||||
"Puis plus tard.\n"
|
||||
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
|
||||
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
|
||||
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"audit-base": {
|
||||
"name": "Audit de base",
|
||||
"icon": "🧹",
|
||||
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
|
||||
"prompt_template": (
|
||||
"Audite la collection demandée :\n"
|
||||
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
|
||||
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
|
||||
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
|
||||
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
|
||||
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
|
||||
},
|
||||
"sprint-review": {
|
||||
"name": "Revue de sprint",
|
||||
"icon": "🏁",
|
||||
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
|
||||
"prompt_template": (
|
||||
"Prépare la revue de sprint à partir des issues Gitea :\n"
|
||||
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
|
||||
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
|
||||
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
|
||||
"changent la priorisation.\n"
|
||||
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
|
||||
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
|
||||
"5. Ne ferme ni ne modifie aucune issue."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
|
||||
|
||||
"recherche-marche": {
|
||||
"name": "Recherche marché",
|
||||
"icon": "🌍",
|
||||
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
|
||||
"prompt_template": (
|
||||
"Produis une étude de marché sourcée sur « <sujet> » :\n"
|
||||
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
|
||||
"« <sujet> prix tarifs 2026 ».\n"
|
||||
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
|
||||
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
|
||||
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
|
||||
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
|
||||
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
|
||||
"5. Indique la date de la recherche : ces données périment vite."
|
||||
),
|
||||
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
|
||||
},
|
||||
"veille-techno": {
|
||||
"name": "Veille technologique",
|
||||
"icon": "🛰️",
|
||||
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
|
||||
"prompt_template": (
|
||||
"Fais une veille technologique sur « <techno> » :\n"
|
||||
"1. Web : dernières annonces et sorties de version (web_search).\n"
|
||||
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
|
||||
"3. Approfondis les annonces importantes (fetch_url).\n"
|
||||
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
|
||||
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
|
||||
"surveiller / ignorer).\n"
|
||||
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
|
||||
),
|
||||
"allowed_tools": ["web_search", "fetch_url", "search_code",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"debug-web": {
|
||||
"name": "Debug web",
|
||||
"icon": "🐛",
|
||||
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
|
||||
"prompt_template": (
|
||||
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
|
||||
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
|
||||
"+ bibliothèque + version.\n"
|
||||
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
|
||||
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
|
||||
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
|
||||
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
|
||||
),
|
||||
"allowed_tools": ["web_search", "search_code", "fetch_url",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
|
||||
|
||||
"triage-incident": {
|
||||
"name": "Triage d'incident",
|
||||
"icon": "🚨",
|
||||
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
|
||||
"prompt_template": (
|
||||
"Traite l'incident suivant : <description>.\n"
|
||||
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
|
||||
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
|
||||
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
|
||||
"et les runbooks existants.\n"
|
||||
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
|
||||
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
|
||||
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
|
||||
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "web_search",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"nettoyage-base": {
|
||||
"name": "Nettoyage de base",
|
||||
"icon": "🧽",
|
||||
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
|
||||
"prompt_template": (
|
||||
"Prépare le nettoyage de la collection « <collection> » :\n"
|
||||
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
|
||||
"valeurs parasites à normaliser, propriétés obsolètes.\n"
|
||||
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
|
||||
"concernées et 3 exemples concrets.\n"
|
||||
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
|
||||
"Le plan est le livrable.\n"
|
||||
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
|
||||
"puis demande la validation."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_collection", "read_page",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"briefing-quotidien": {
|
||||
"name": "Briefing quotidien",
|
||||
"icon": "🌅",
|
||||
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
|
||||
"prompt_template": (
|
||||
"Prépare le briefing du jour :\n"
|
||||
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
|
||||
"search_workspace) — uniquement des faits datés.\n"
|
||||
"2. Décisions en attente : documents de décision sans statut clair.\n"
|
||||
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
|
||||
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
|
||||
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
|
||||
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import logging
|
||||
import time
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -381,7 +382,7 @@ class SyncEngine:
|
||||
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
||||
cid = payload.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
delete_collections(conn, [cid])
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "server_version": None}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""FlowDeck — My Tasks : sources de tâches et normalisation.
|
||||
|
||||
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
|
||||
explicite** (``collections.is_task``) et le mapping explicite de ses trois
|
||||
propriétés requises :
|
||||
|
||||
=========================== ==========================================
|
||||
Rôle Colonne sur ``collections``
|
||||
=========================== ==========================================
|
||||
``assignee`` (Assigné à) ``task_assignee_prop``
|
||||
``status`` (Statut) ``task_status_prop``
|
||||
``due`` (Échéance) ``task_due_prop``
|
||||
=========================== ==========================================
|
||||
|
||||
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
|
||||
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
|
||||
``collect_tasks`` — donc une même tâche ne peut pas être rendue
|
||||
différemment selon l'appelant.
|
||||
|
||||
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
|
||||
pas seulement le workspace actif — le tableau de bord est un poste de
|
||||
travail transverse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unicodedata
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
live_row_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
|
||||
MAX_TASK_SOURCES = 10
|
||||
|
||||
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
|
||||
TASK_ROLES = ("assignee", "status", "due")
|
||||
|
||||
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
|
||||
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
|
||||
ROLE_TYPES = {
|
||||
"assignee": ("person",),
|
||||
"status": ("status", "select"),
|
||||
"due": ("date",),
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"assignee": "Assigné à",
|
||||
"status": "Statut",
|
||||
"due": "Date d'échéance",
|
||||
}
|
||||
|
||||
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
|
||||
#: sans accents). On compare des formes normalisées et non des libellés exacts :
|
||||
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
|
||||
#: reconnus, quelle que soit la casse et l'accentuation.
|
||||
#:
|
||||
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
|
||||
#: commence comme « clos » mais n'est pas un statut terminé.
|
||||
DONE_WORDS = frozenset({
|
||||
# anglais
|
||||
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
|
||||
# français (masculin / féminin, singulier / pluriel)
|
||||
"clos", "close", "termine", "terminee", "termines", "terminees",
|
||||
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
|
||||
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
|
||||
})
|
||||
|
||||
|
||||
def _normalize(value) -> str:
|
||||
"""Minuscules, accents retirés, espaces compactés."""
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return " ".join(text.lower().split())
|
||||
|
||||
|
||||
def is_done(status: str) -> bool:
|
||||
"""Le statut vaut-il « terminé » ?
|
||||
|
||||
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
|
||||
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
|
||||
"""
|
||||
norm = _normalize(status)
|
||||
if not norm:
|
||||
return False
|
||||
first = norm.split()[0] if norm.split() else ""
|
||||
return first.strip("!?.,:;()") in DONE_WORDS
|
||||
|
||||
|
||||
def user_today(conn, user_id: int) -> date:
|
||||
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
|
||||
|
||||
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
|
||||
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
|
||||
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
|
||||
l'inverse) dès que l'utilisateur est loin d'UTC.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT timezone FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(tz_name)).date()
|
||||
except Exception: # fuseau inconnu / base tz absente → UTC
|
||||
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
|
||||
return datetime.now(UTC).date()
|
||||
|
||||
|
||||
def task_source_columns() -> str:
|
||||
"""Colonnes de ``collections`` lues par les requêtes de sources.
|
||||
|
||||
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
|
||||
base antérieure elles vaudront NULL de toute façon.
|
||||
|
||||
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
|
||||
qualification, ``id`` est ambigu.
|
||||
"""
|
||||
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
|
||||
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
|
||||
|
||||
|
||||
def list_task_sources(conn, user_id: int) -> list[dict]:
|
||||
"""Bases de tâches configurées par l'utilisateur, tous workspaces
|
||||
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
|
||||
``missing_roles`` pour piloter l'invite de conversion.
|
||||
|
||||
Deux exclusions :
|
||||
|
||||
* une base dont la page hôte est à la corbeille (ou supprimée) est
|
||||
**retirée** : sans ce filtre, une base portée par un document supprimé
|
||||
continuerait d'alimenter My Tasks — le bug d'origine ;
|
||||
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
|
||||
pas listée (une base sans workspace rattachable n'appartient à personne).
|
||||
|
||||
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
|
||||
celui de la page hôte) : une base créée depuis un document a
|
||||
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
|
||||
My Tasks alors qu'elle venait d'être convertie.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
ws_sql = effective_workspace_sql("c")
|
||||
rows = conn.execute(
|
||||
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
|
||||
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
|
||||
AS workspace_name
|
||||
FROM collections c
|
||||
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
[*ws_ids],
|
||||
).fetchall()
|
||||
|
||||
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
|
||||
# jeu de bases vivantes qu'une fois par workspace.
|
||||
live_by_ws: dict[int, set[int]] = {}
|
||||
sources = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d.get("ws_effective")
|
||||
if ws not in live_by_ws:
|
||||
live_by_ws[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live_by_ws[ws]:
|
||||
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
|
||||
continue
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
d["configured"] = not missing
|
||||
d["missing_roles"] = missing
|
||||
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
|
||||
sources.append(d)
|
||||
return sources
|
||||
|
||||
|
||||
def _status_options(conn, prop_id) -> list[str]:
|
||||
"""Libellés du statut de la base source (colonnes du Kanban)."""
|
||||
if not prop_id:
|
||||
return []
|
||||
row = conn.execute(
|
||||
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
try:
|
||||
opts = json.loads(row["options_json"] or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
|
||||
if isinstance(o, str) or isinstance(o, dict)]
|
||||
|
||||
|
||||
def source_state(conn, collection_id: int) -> dict:
|
||||
"""État de conversion d'une base pour la modale « convertir en base de
|
||||
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
|
||||
coll = conn.execute(
|
||||
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
return {"exists": False}
|
||||
|
||||
d = dict(coll)
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
|
||||
"WHERE collection_id=? ORDER BY position, id",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"collection_id": collection_id,
|
||||
"name": d.get("name"),
|
||||
"is_task": bool(d.get("is_task")),
|
||||
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
|
||||
"missing_roles": missing,
|
||||
"configured": not missing,
|
||||
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
|
||||
# autre rôle est exclue — Notion n'autorise pas le double emploi).
|
||||
"candidates": {
|
||||
role: [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
|
||||
]
|
||||
for role in TASK_ROLES
|
||||
},
|
||||
"all_properties": [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
],
|
||||
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
|
||||
"""Nombre de bases déjà connectées (pour la limite de 10)."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
|
||||
(workspace_id,),
|
||||
).fetchone()
|
||||
n = row[0] if row else 0
|
||||
if exclude_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
n -= 1
|
||||
return max(n, 0)
|
||||
|
||||
|
||||
def _row_value(raw: dict, prop_id):
|
||||
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
|
||||
lignes historicisées écrites avant le passage aux clés indexées)."""
|
||||
if prop_id is None:
|
||||
return None
|
||||
if prop_id in raw:
|
||||
return raw[prop_id]
|
||||
return raw.get(str(prop_id))
|
||||
|
||||
|
||||
def _person_values(value) -> list:
|
||||
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
|
||||
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
out: list = []
|
||||
for v in value:
|
||||
out.extend(_person_values(v))
|
||||
return out
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _person_label(v) -> str:
|
||||
if isinstance(v, dict):
|
||||
return str(v.get("login") or v.get("full_name") or "")
|
||||
return str(v)
|
||||
|
||||
|
||||
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
|
||||
"""L'assigné est-il l'utilisateur courant ?
|
||||
|
||||
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
|
||||
priorité (stable), puis le login en repli pour les lignes historicisées
|
||||
qui ne stockent qu'un nom.
|
||||
"""
|
||||
if not login:
|
||||
return False
|
||||
for v in values:
|
||||
if isinstance(v, dict):
|
||||
if v.get("id") is not None and str(v["id"]) == str(user_id):
|
||||
return True
|
||||
if _person_label(v).strip().lower() == login.strip().lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
|
||||
login: str = "") -> list[dict]:
|
||||
"""Tâches normalisées de toutes les bases connectées.
|
||||
|
||||
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
|
||||
les identifiants des propriétés à écrire pour une édition en direct
|
||||
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
|
||||
deviner quoi écrire.
|
||||
"""
|
||||
tasks: list[dict] = []
|
||||
today = user_today(conn, user_id)
|
||||
|
||||
for src in list_task_sources(conn, user_id):
|
||||
if not src["configured"]:
|
||||
# Base connectée mais mapping incomplet : elle n'alimente pas
|
||||
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
|
||||
continue
|
||||
status_prop = src["task_status_prop"]
|
||||
due_prop = src["task_due_prop"]
|
||||
assignee_prop = src["task_assignee_prop"]
|
||||
|
||||
for row_id in live_row_ids(conn, src["id"]):
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, created_at, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
persons = _person_values(_row_value(raw, assignee_prop))
|
||||
assignees = [_person_label(p) for p in persons if _person_label(p)]
|
||||
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
|
||||
continue
|
||||
|
||||
status = _row_value(raw, status_prop)
|
||||
if isinstance(status, list):
|
||||
status = status[0] if status else ""
|
||||
status = str(status or "")
|
||||
|
||||
due_raw = _row_value(raw, due_prop)
|
||||
due = str(due_raw or "")[:10]
|
||||
|
||||
due_state = ""
|
||||
if due:
|
||||
try:
|
||||
d = date.fromisoformat(due)
|
||||
except ValueError:
|
||||
d = None
|
||||
if d:
|
||||
due_state = ("overdue" if d < today
|
||||
else "today" if d == today
|
||||
else "upcoming")
|
||||
|
||||
tasks.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"icon": row["icon"] or "📄",
|
||||
"created_at": row["created_at"],
|
||||
"collection_id": src["id"],
|
||||
"collection_name": src["name"],
|
||||
"collection_icon": src["icon"] or "📋",
|
||||
"workspace_id": src["workspace_id"],
|
||||
"workspace_name": src["workspace_name"],
|
||||
"assignees": assignees,
|
||||
"status": status,
|
||||
"status_options": src["status_options"],
|
||||
"done": is_done(status),
|
||||
"due": due,
|
||||
"due_state": due_state,
|
||||
"status_prop": status_prop,
|
||||
"due_prop": due_prop,
|
||||
"assignee_prop": assignee_prop,
|
||||
})
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
|
||||
sort: str = "due", today: date | None = None) -> list[dict]:
|
||||
"""Filtres et tri globaux de My Tasks (portés par l'URL).
|
||||
|
||||
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
|
||||
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
|
||||
« cette semaine » ne cibleront pas le même jour que `due_state`.
|
||||
"""
|
||||
out = tasks
|
||||
if hide_done:
|
||||
out = [t for t in out if not t["done"]]
|
||||
if due and due != "all":
|
||||
if due == "today":
|
||||
out = [t for t in out if t["due_state"] == "today"]
|
||||
elif due == "overdue":
|
||||
out = [t for t in out if t["due_state"] in ("today", "overdue")]
|
||||
elif due == "week":
|
||||
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
|
||||
# datée dans six mois apparaîtrait sous « Cette semaine ».
|
||||
ref = today or datetime.now(UTC).date()
|
||||
limit = (ref + timedelta(days=7)).isoformat()
|
||||
out = [t for t in out
|
||||
if t["due"] and (t["due_state"] in ("today", "overdue")
|
||||
or t["due"] <= limit)]
|
||||
|
||||
if sort == "status":
|
||||
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
|
||||
(t["due"] or "9999")))
|
||||
if sort == "source":
|
||||
return sorted(out, key=lambda t: (t["collection_name"].lower(),
|
||||
(t["due"] or "9999"), t["title"].lower()))
|
||||
if sort == "created":
|
||||
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
|
||||
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
|
||||
# les tâches sans date — comme une liste de rappels.
|
||||
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
|
||||
t["done"], t["title"].lower()))
|
||||
@@ -15,12 +15,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
delete_collections(conn, [cid])
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
@@ -877,6 +879,197 @@ class CreateGiteaIssue(Tool):
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Web tools ══════════════════════════
|
||||
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
|
||||
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
|
||||
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
|
||||
# re-vérifiée à chaque saut de redirection.
|
||||
|
||||
|
||||
class WebSearch(Tool):
|
||||
name = "web_search"
|
||||
description = (
|
||||
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
|
||||
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
|
||||
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "requête de recherche"},
|
||||
"num_results": {"type": "integer",
|
||||
"description": "nombre de résultats (1-10, défaut 5)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.web_search import available_providers, search_web
|
||||
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||
try:
|
||||
num = max(1, min(int(args.get("num_results") or 5), 10))
|
||||
except (TypeError, ValueError):
|
||||
num = 5
|
||||
try:
|
||||
results, provider = await search_web(query, num)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Recherche web indisponible: {exc}")
|
||||
if not results:
|
||||
hint = ""
|
||||
if not available_providers().get(provider, True):
|
||||
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="web_search",
|
||||
data={"query": query, "provider": provider, "results": results,
|
||||
"count": len(results)},
|
||||
message=f"{len(results)} résultat(s) via {provider}",
|
||||
)
|
||||
|
||||
|
||||
class FetchUrl(Tool):
|
||||
name = "fetch_url"
|
||||
description = (
|
||||
"Télécharge une page web et la convertit en Markdown (titres, listes, "
|
||||
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
|
||||
"web_search pour approfondir un résultat. Les URLs internes / privées "
|
||||
"sont refusées."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL http(s) de la page"},
|
||||
"max_chars": {"type": "integer",
|
||||
"description": "longueur max du markdown renvoyé (défaut 20000)"},
|
||||
},
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
raw = str(args.get("url") or "").strip()
|
||||
if not raw:
|
||||
return ToolResult(status="error", tool=self.name, message="url vide")
|
||||
# « example.com/page » → https://example.com/page, mais un schéma
|
||||
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
|
||||
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
|
||||
raw = "https://" + raw
|
||||
try:
|
||||
url = _validate_url(raw)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
|
||||
try:
|
||||
max_chars = max(500, min(int(args.get("max_chars")
|
||||
or settings.web_fetch_max_chars), 60000))
|
||||
except (TypeError, ValueError):
|
||||
max_chars = int(settings.web_fetch_max_chars or 20000)
|
||||
|
||||
current = url
|
||||
resp = None
|
||||
try:
|
||||
async with shared_client(timeout=15.0, follow_redirects=False,
|
||||
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
|
||||
for _ in range(6):
|
||||
resp = await client.get(current)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("location")
|
||||
if not loc:
|
||||
raise ValueError("redirection sans cible")
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = urljoin(current, loc)
|
||||
# re-validation : une URL publique peut rediriger vers 169.254.x
|
||||
current = _validate_url(current)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
break
|
||||
else:
|
||||
raise ValueError("trop de redirections")
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Page injoignable: {exc}")
|
||||
if resp is None:
|
||||
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
|
||||
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
body = resp.text
|
||||
if "html" not in ctype and "xhtml" not in ctype:
|
||||
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
|
||||
markdown = body
|
||||
meta: dict = {}
|
||||
else:
|
||||
meta = parse_og(body, current)
|
||||
markdown = _html_to_markdown(body)
|
||||
|
||||
truncated = len(markdown) > max_chars
|
||||
if truncated:
|
||||
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
|
||||
|
||||
title = (meta.get("title") or "").strip() or current
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="web_page", target_id=current,
|
||||
data={"url": current, "title": title[:200], "markdown": markdown,
|
||||
"truncated": truncated, "chars": len(markdown)},
|
||||
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
|
||||
)
|
||||
|
||||
|
||||
class SearchCode(Tool):
|
||||
name = "search_code"
|
||||
description = (
|
||||
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
|
||||
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
|
||||
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
|
||||
"kind": {"type": "string",
|
||||
"description": "repositories | code | issues (défaut repositories)"},
|
||||
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.web_search import search_github
|
||||
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||
kind = str(args.get("kind") or "repositories")
|
||||
try:
|
||||
limit = max(1, min(int(args.get("limit") or 5), 10))
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
try:
|
||||
results = await search_github(query, kind, limit)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Recherche GitHub impossible: {exc}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="github",
|
||||
target_id=f"{kind}:{query}",
|
||||
data={"query": query, "kind": kind, "results": results, "count": len(results)},
|
||||
message=f"{len(results)} résultat(s) GitHub ({kind})",
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
@@ -887,6 +1080,7 @@ TOOL_CLASSES = [
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
WebSearch, FetchUrl, SearchCode,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
delete_collections,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
page_id = row["id"]
|
||||
# A purged host page takes its database with it, otherwise the
|
||||
# workspace kept advertising tasks whose document is gone.
|
||||
collections = collections_hosted_by_page(conn, page_id)
|
||||
# Promote direct children to the deleted page's parent so no live
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
|
||||
|
||||
Alimente les deux tools réseau qui manquaient au registre : la recherche
|
||||
**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
|
||||
|
||||
Choix de conception :
|
||||
|
||||
* **Provider configurable** (``settings.web_search_provider``) : Exa
|
||||
(recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
|
||||
repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
|
||||
il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
|
||||
garder le skill utilisable sur une instance sans compte externe.
|
||||
* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
|
||||
pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
|
||||
``app.services.importers.url_fetch`` / ``og_fetcher``.
|
||||
* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
|
||||
que l'IA puisse citer au lieu d'inventer.
|
||||
|
||||
La couche réseau est injectable (``transport``) pour que les tests n'aient
|
||||
jamais besoin du réseau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
|
||||
|
||||
#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
|
||||
#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
|
||||
_BLOCKED_RESULT_HOSTS = frozenset({
|
||||
"duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
|
||||
})
|
||||
|
||||
EXA_ENDPOINT = "https://api.exa.ai/search"
|
||||
DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
|
||||
GITHUB_API = "https://api.github.com"
|
||||
|
||||
MAX_RESULTS = 10
|
||||
_SNIPPET_CHARS = 300
|
||||
|
||||
|
||||
def _clean(text: str | None) -> str:
|
||||
return re.sub(r"\s+", " ", (text or "")).strip()
|
||||
|
||||
|
||||
def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
|
||||
text = _clean(text)
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _is_resultable(url: str) -> bool:
|
||||
"""Ignore les redirections vers le moteur lui-même et les URLs non http."""
|
||||
parsed = urlparse(url or "")
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
|
||||
|
||||
|
||||
# ══════════════════════ Exa ══════════════════════
|
||||
|
||||
|
||||
async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
|
||||
key = (settings.exa_api_key or "").strip()
|
||||
if not key:
|
||||
return []
|
||||
payload = {
|
||||
"query": query,
|
||||
"numResults": max(1, min(num_results, MAX_RESULTS)),
|
||||
"contents": {"text": {"maxCharacters": 1200}},
|
||||
}
|
||||
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.post(
|
||||
EXA_ENDPOINT,
|
||||
json=payload,
|
||||
headers={"x-api-key": key, "Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Exa HTTP {resp.status_code}")
|
||||
results = []
|
||||
for item in (resp.json() or {}).get("results", []):
|
||||
url = item.get("url") or ""
|
||||
if not _is_resultable(url):
|
||||
continue
|
||||
results.append({
|
||||
"title": _clip(item.get("title") or url, 160),
|
||||
"url": url,
|
||||
"snippet": _clip(item.get("text") or item.get("summary") or ""),
|
||||
"published": (item.get("publishedDate") or "")[:10],
|
||||
"source": "exa",
|
||||
})
|
||||
return results[:MAX_RESULTS]
|
||||
|
||||
|
||||
# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
|
||||
|
||||
_DDG_ROW_RE = re.compile(
|
||||
r"<a[^>]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>",
|
||||
re.I | re.S,
|
||||
)
|
||||
_DDG_SNIPPET_RE = re.compile(
|
||||
r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S
|
||||
)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _unwrap_ddg(href: str) -> str:
|
||||
"""DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``."""
|
||||
if not href:
|
||||
return ""
|
||||
parsed = urlparse(htmlmod.unescape(href))
|
||||
if "uddg" in (parsed.query or ""):
|
||||
values = parse_qs(parsed.query).get("uddg")
|
||||
if values:
|
||||
return values[0]
|
||||
return href
|
||||
|
||||
|
||||
async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
|
||||
kwargs: dict[str, Any] = {"timeout": 12.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.post(
|
||||
DDG_ENDPOINT,
|
||||
data={"q": query, "kl": "wt-wt"},
|
||||
headers={"User-Agent": USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
|
||||
body = resp.text
|
||||
links = list(_DDG_ROW_RE.finditer(body))
|
||||
snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
|
||||
results = []
|
||||
for idx, match in enumerate(links):
|
||||
url = _unwrap_ddg(match.group("href"))
|
||||
if not _is_resultable(url):
|
||||
continue
|
||||
results.append({
|
||||
"title": _clean(_TAG_RE.sub("", match.group("title"))),
|
||||
"url": url,
|
||||
"snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
|
||||
"published": "",
|
||||
"source": "duckduckgo",
|
||||
})
|
||||
if len(results) >= min(num_results, MAX_RESULTS):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════ API publique ══════════════════════
|
||||
|
||||
|
||||
async def search_web(query: str, num_results: int = 5,
|
||||
transport=None) -> tuple[list[dict], str]:
|
||||
"""Recherche web → ``(results, provider)``.
|
||||
|
||||
Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
|
||||
une liste vide — c'est au tool de traduire ça en message actionnable pour
|
||||
le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return [], ""
|
||||
|
||||
provider = (settings.web_search_provider or "exa").strip().lower()
|
||||
order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
|
||||
|
||||
errors: list[str] = []
|
||||
for name in order:
|
||||
try:
|
||||
if name == "exa":
|
||||
results = await _search_exa(q, num_results, transport)
|
||||
else:
|
||||
results = await _search_duckduckgo(q, num_results, transport)
|
||||
except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
|
||||
logger.info("web_search provider %s failed: %s", name, exc)
|
||||
errors.append(f"{name}: {exc}")
|
||||
continue
|
||||
if results:
|
||||
return results, name
|
||||
|
||||
if errors:
|
||||
logger.warning("web_search sans résultat — %s", "; ".join(errors))
|
||||
return [], order[0]
|
||||
return [], order[0]
|
||||
|
||||
|
||||
def available_providers() -> dict[str, bool]:
|
||||
"""Quel provider est réellement utilisable (affiché dans les settings)."""
|
||||
return {
|
||||
"exa": bool((settings.exa_api_key or "").strip()),
|
||||
"duckduckgo": True,
|
||||
"github": True, # sans PAT : 10 req/min suffisent pour un usage agent
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════ GitHub ══════════════════════
|
||||
|
||||
_GH_KINDS = {
|
||||
"repositories": "repos",
|
||||
"repos": "repos",
|
||||
"code": "code",
|
||||
"issues": "issues",
|
||||
}
|
||||
|
||||
|
||||
def _gh_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28"}
|
||||
token = (settings.github_token or "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _gh_item_name(item: dict, kind: str) -> str:
|
||||
if kind == "repos":
|
||||
return item.get("full_name") or item.get("name") or ""
|
||||
if kind == "code":
|
||||
return item.get("path") or ""
|
||||
return item.get("title") or f"#{item.get('number')}"
|
||||
|
||||
|
||||
def _gh_item_url(item: dict, kind: str) -> str:
|
||||
if kind == "code":
|
||||
# L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
|
||||
repo = (item.get("repository") or {}).get("full_name", "")
|
||||
return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
|
||||
return item.get("html_url") or ""
|
||||
|
||||
|
||||
def _gh_item_snippet(item: dict, kind: str) -> str:
|
||||
if kind == "code":
|
||||
return ""
|
||||
if kind == "repos":
|
||||
return _clip(item.get("description") or "")
|
||||
body = _clean(item.get("body") or "")
|
||||
return _clip(body) if body else _clip(item.get("title") or "")
|
||||
|
||||
|
||||
async def search_github(query: str, kind: str = "repositories", limit: int = 5,
|
||||
transport=None) -> list[dict]:
|
||||
"""Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
|
||||
|
||||
Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
|
||||
per_page = max(1, min(int(limit or 5), MAX_RESULTS))
|
||||
|
||||
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API}/search/{k}",
|
||||
params={"q": q, "per_page": per_page},
|
||||
headers=_gh_headers(),
|
||||
)
|
||||
if resp.status_code == 403:
|
||||
raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
|
||||
if resp.status_code == 422:
|
||||
raise RuntimeError("GitHub: requête de recherche invalide (422)")
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"GitHub HTTP {resp.status_code}")
|
||||
|
||||
items = (resp.json() or {}).get("items", [])
|
||||
results = []
|
||||
for item in items[:per_page]:
|
||||
results.append({
|
||||
"title": _gh_item_name(item, k),
|
||||
"url": _gh_item_url(item, k),
|
||||
"snippet": _gh_item_snippet(item, k),
|
||||
"stars": item.get("stargazers_count") if k == "repos" else None,
|
||||
"state": item.get("state") if k == "issues" else "",
|
||||
"source": f"github/{k}",
|
||||
})
|
||||
return results
|
||||
@@ -163,9 +163,9 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
||||
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
|
||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,13 +125,7 @@
|
||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Side Peek Panel for Database Pages -->
|
||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
|
||||
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
|
||||
</div>
|
||||
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
|
||||
</div>
|
||||
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
|
||||
database_table.js (ensureDbPeek) : une seule source de markup pour
|
||||
la page de base et les bases embarquées dans un document. -->
|
||||
</div>
|
||||
|
||||
@@ -107,4 +107,6 @@
|
||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||
</style>
|
||||
<script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script>
|
||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/task_db_link.js?v={{ asset_version }}" defer></script>
|
||||
|
||||
@@ -60,14 +60,14 @@
|
||||
{# ── Collapsed "…" segment ── #}
|
||||
<template x-if="crumb.ellipsis">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
||||
@mouseenter="hoverEllipsis()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(h.url)">
|
||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="h.label"></span>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
||||
@mouseenter="hoverMenu(crumb.origIndex)"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link"
|
||||
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||
@@ -110,7 +110,7 @@
|
||||
<template x-for="item in activeItems" :key="item.id">
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@click.stop="go(item.url); closeAll()">
|
||||
@click.stop="goClose(item.url)">
|
||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="item.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||
@@ -118,7 +118,7 @@
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(s.url)">
|
||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="s.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
|
||||
wsId: 0,
|
||||
openIdx: null,
|
||||
ellipsisOpen: false,
|
||||
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
|
||||
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
|
||||
goClose(u){ this.go(u); this.closeAll(); },
|
||||
loading: false,
|
||||
cache: {},
|
||||
activeItems: [],
|
||||
|
||||
@@ -88,7 +88,7 @@ document.addEventListener('alpine:init', function () {
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
csrf() {
|
||||
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
return getCsrf();
|
||||
},
|
||||
refreshCount() {
|
||||
var self = this;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
@keydown.escape="closeInviteSuggest()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
@@ -169,7 +169,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'edit')"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -179,7 +179,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'comment')"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -189,7 +189,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'view')"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -199,7 +199,7 @@
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeShare(a.id); a.permOpen=false"
|
||||
@click="removeShareAndClose(a)"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('invited')"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -243,7 +243,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('anyone')"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -312,7 +312,7 @@
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,16 +498,16 @@
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
|
||||
<div class="more-menu-item" @click="moreToggleLock()">
|
||||
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
|
||||
<div class="more-menu-item" @click="moreFullWidth()">
|
||||
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
|
||||
<div class="more-menu-item" @click="moreFontSmall()">
|
||||
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
|
||||
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
@@ -546,13 +546,13 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@input="markAndSave()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
@@ -583,7 +583,7 @@
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
@@ -856,7 +856,7 @@
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
<span x-text="fmtImportSize(importFile)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
@@ -117,8 +123,8 @@
|
||||
} catch(e) {}
|
||||
},
|
||||
saveProfile() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
|
||||
method: 'PUT', headers: { 'X-CSRF-Token': token }
|
||||
}).then(() => {
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
<style>
|
||||
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
|
||||
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
|
||||
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
||||
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:var(--fd-agent-width,470px);max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
||||
#fd-agent-panel.open{transform:translateX(0)}
|
||||
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
|
||||
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
|
||||
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* Resize handle (left edge) */
|
||||
.fd-ap-resize{position:absolute;top:0;bottom:0;left:0;width:6px;cursor:col-resize;z-index:5}
|
||||
.fd-ap-resize::before{content:"";position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;border-radius:2px;transition:background .12s ease}
|
||||
.fd-ap-resize:hover::before,body.fd-ap-resizing .fd-ap-resize::before{background:var(--accent,#2383E2)}
|
||||
body.fd-ap-resizing{cursor:col-resize!important;user-select:none}
|
||||
body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
|
||||
/* Header */
|
||||
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
|
||||
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
|
||||
@@ -173,6 +180,26 @@
|
||||
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
||||
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
||||
|
||||
/* Menu + (sections) — réutilise le skin du menu de mentions */
|
||||
.fd-ap-plus-menu{max-height:min(340px,60vh)}
|
||||
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
|
||||
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
|
||||
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
|
||||
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
|
||||
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fd-plus-filter{padding:7px 11px 2px}
|
||||
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
|
||||
.fd-plus-form{padding:4px 11px 12px}
|
||||
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
|
||||
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
|
||||
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
|
||||
.fd-plus-file{display:none}
|
||||
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
|
||||
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
|
||||
.fd-plus-crumb-it:hover{text-decoration:underline}
|
||||
|
||||
/* Toast */
|
||||
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
||||
@@ -189,7 +216,7 @@
|
||||
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
|
||||
.fd-agent-msg-markdown li{margin:2px 0}
|
||||
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
||||
.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
|
||||
.fd-agent-msg-markdown pre{background:var(--bg-code,var(--bg-tertiary,#1f1f1f));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0;color:var(--text,#ececec)}
|
||||
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
|
||||
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
|
||||
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
@@ -211,6 +238,7 @@
|
||||
|
||||
{# ── Panel ── #}
|
||||
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
|
||||
<div class="fd-ap-resize" x-ref="resizeHandle" role="separator" aria-orientation="vertical" aria-label="Redimensionner le panneau" tabindex="0" title="Glisser pour redimensionner — double-clic pour réinitialiser"></div>
|
||||
<div class="fd-ap-head">
|
||||
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
|
||||
<div class="fd-ap-head-main">
|
||||
@@ -231,7 +259,7 @@
|
||||
|
||||
<div class="fd-ap-tabs">
|
||||
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-body">
|
||||
@@ -305,7 +333,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
|
||||
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
|
||||
<template x-if="m.proposal">
|
||||
<div class="fd-proposal-bar">
|
||||
@@ -372,6 +400,52 @@
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
|
||||
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
|
||||
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
|
||||
<span class="fd-plus-title" x-text="plusTitle"></span>
|
||||
</div>
|
||||
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
|
||||
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
|
||||
</div>
|
||||
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
|
||||
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
|
||||
<template x-for="(c, ci) in plusCrumb" :key="c.id">
|
||||
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
|
||||
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
|
||||
<template x-for="(it, i) in plusItems" :key="it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
|
||||
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
</div>
|
||||
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
|
||||
<label for="fd-plus-skill-name">Nom</label>
|
||||
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
|
||||
<label for="fd-plus-skill-desc">Description</label>
|
||||
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
|
||||
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
|
||||
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
|
||||
<div class="fd-proposal-bar">
|
||||
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
|
||||
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
|
||||
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
|
||||
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
||||
<div class="fd-chip-row" x-show="mentionChips.length">
|
||||
@@ -398,7 +472,7 @@
|
||||
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
||||
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
||||
<div class="fd-ap-toolbar">
|
||||
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
|
||||
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
|
||||
<span class="fd-ap-tip" x-text="composerHint"></span>
|
||||
<div class="fd-ap-tools">
|
||||
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
||||
|
||||
+494
-45
@@ -13,6 +13,11 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
|
||||
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
|
||||
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||
aucune feuille de style (composants blancs en thème sombre). -->
|
||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
@@ -115,13 +120,22 @@
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
|
||||
// tous les scripts inline/externes de la page (welcome.html, isolé de
|
||||
// base, garde sa propre lecture du cookie).
|
||||
window.getCsrf = function() {
|
||||
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
@@ -179,8 +193,8 @@
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
x-effect="syncSidebarClass()"
|
||||
x-init="initSidebarSort()"
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
|
||||
@@ -232,24 +246,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation icons row -->
|
||||
<div class="sidebar-nav-row">
|
||||
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
|
||||
<!-- Navigation tabs row — collapsible pills: the active tab expands with
|
||||
its label, the others collapse to their icon. Selecting a tab replaces
|
||||
the whole sidebar content (Home tree / Chat history / Meetings / Inbox). -->
|
||||
<div class="sidebar-nav-row" role="tablist" aria-label="Sidebar sections">
|
||||
<a href="{% if local_ws_id %}/local-workspace?ws={{ local_ws_id }}{% elif local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn nav-tab-btn" data-tab="home" :class="{ active: sidebarTab === 'home' }" @click="setSidebarTab('home')" :aria-selected="sidebarTab === 'home'" role="tab" title="Home">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
||||
Home
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'home' ? 'display:inline-block' : 'display:none'">Home</span>
|
||||
</a>
|
||||
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="chat" :class="{ active: sidebarTab === 'chat' }" @click="setSidebarTab('chat')" :aria-selected="sidebarTab === 'chat'" role="tab" title="Chat (Ctrl+J)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'chat' ? 'display:inline-block' : 'display:none'">Chat</span>
|
||||
<span class="nav-tab-badge" x-show="chatHistory.length" x-cloak x-text="chatHistory.length" :style="sidebarTab === 'chat' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button class="nav-icon-btn" title="Inbox">
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="meetings" :class="{ active: sidebarTab === 'meetings' }" @click="setSidebarTab('meetings')" :aria-selected="sidebarTab === 'meetings'" role="tab" title="Meetings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'meetings' ? 'display:inline-block' : 'display:none'">Meeting</span>
|
||||
<span class="nav-tab-badge" x-show="meetingToday.length" x-cloak x-text="meetingToday.length" :style="sidebarTab === 'meetings' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="inbox" :class="{ active: sidebarTab === 'inbox' }" @click="setSidebarTab('inbox')" :aria-selected="sidebarTab === 'inbox'" role="tab" title="Inbox">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'inbox' ? 'display:inline-block' : 'display:none'">Inbox</span>
|
||||
<span class="nav-tab-badge nav-tab-badge-accent" x-show="inboxUnread > 0" x-cloak x-text="inboxUnread > 99 ? '99+' : inboxUnread" :style="sidebarTab === 'inbox' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
||||
<button type="button" class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
|
||||
<!-- ── TAB: Home (workspace tree, recents, favorites, libraries…) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'home'" x-cloak>
|
||||
<!-- Workspace / Private -->
|
||||
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
|
||||
@@ -263,8 +291,8 @@
|
||||
</div>
|
||||
{% if has_active_workspace %}
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
|
||||
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
|
||||
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
|
||||
</div>
|
||||
@@ -299,7 +327,7 @@
|
||||
<span class="section-label">Repository (Gitea)</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +349,7 @@
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
{% if has_active_workspace %}
|
||||
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
|
||||
{% endif %}
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
||||
</div>
|
||||
@@ -398,7 +426,7 @@
|
||||
<ul class="sidebar-items" data-section="agents">
|
||||
<template x-for="a in agentList" :key="a.id">
|
||||
<li class="sidebar-item" @click="agentOpen(a.id)">
|
||||
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
||||
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
|
||||
<span class="page-name" x-text="a.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -532,12 +560,7 @@
|
||||
<button class="scp-done" @click="toggleCustomize()">Done</button>
|
||||
</div>
|
||||
<div class="scp-list">
|
||||
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
||||
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
|
||||
meetings:{visible:true,order:2},recents:{visible:true,order:3},
|
||||
favorites:{visible:true,order:4},agents:{visible:true,order:5},
|
||||
shared:{visible:true,order:6},published:{visible:true,order:7}
|
||||
})" :key="key">
|
||||
<template x-for="(cfg, key) in sidebarSections()" :key="key">
|
||||
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
||||
<div class="scp-item-left">
|
||||
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
|
||||
@@ -567,6 +590,123 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div><!-- /sidebar-tab-panel home -->
|
||||
|
||||
<!-- ── TAB: Chat (agent shortcuts + conversation history) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'chat'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Chat</span>
|
||||
<button type="button" class="sb-panel-action" @click="chatNewConversation()" title="New conversation">{{ fd_icon("plus",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Circular agent shortcuts -->
|
||||
<div class="sb-agent-row" x-show="agentList.length">
|
||||
<template x-for="a in agentList.slice(0, 8)" :key="a.id">
|
||||
<button type="button" class="sb-agent-avatar" :title="a.name" @click="agentOpen(a.id)">
|
||||
<span x-text="a.icon || '\u{1F916}'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-subgroup">
|
||||
<div class="sidebar-subgroup-label">Past 30 days</div>
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="c in chatRecent" :key="c.id">
|
||||
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!chatLoading && !chatRecent.length">
|
||||
<span class="page-name text-dim">No recent conversations</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-subgroup" x-show="chatOlder.length">
|
||||
<div class="sidebar-subgroup-label">Older</div>
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="c in chatOlder" :key="c.id">
|
||||
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item empty-hint" x-show="chatLoading" style="padding:8px;">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Meeting (upcoming events + quick actions) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'meetings'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Meeting</span>
|
||||
<button type="button" class="sb-panel-action" @click="loadMeetings(true)" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||
</div>
|
||||
|
||||
<div class="sb-filter-row">
|
||||
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
|
||||
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
|
||||
</div>
|
||||
|
||||
<div class="sb-quick-actions">
|
||||
<button type="button" class="sb-quick-btn" @click="meetingTranscribe()">{{ fd_icon("upload",14) }} Transcribe a podcast</button>
|
||||
<button type="button" class="sb-quick-btn" @click="meetingNote()">{{ fd_icon("sparkles",14) }} New AI meeting note</button>
|
||||
<input type="file" x-ref="meetingAudio" accept="audio/*" class="sb-hidden-input" @change="meetingUpload($event)">
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
|
||||
<li class="sidebar-item" @click="meetingOpen(ev)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
|
||||
<span class="page-name" x-text="ev.title"></span>
|
||||
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
|
||||
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
|
||||
</li>
|
||||
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Inbox (notifications) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'inbox'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Inbox <span class="sb-panel-count" x-show="inboxUnread > 0" x-text="inboxUnread"></span></span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button type="button" class="sb-panel-action" x-show="inboxUnread > 0" @click="inboxMarkAllRead()" title="Mark all read">{{ fd_icon("check-square",14) }}</button>
|
||||
<button type="button" class="sb-panel-action" @click="inboxFilter = (inboxFilter === 'all' ? 'unread' : 'all')"
|
||||
:title="inboxFilter === 'all' ? 'Show unread only' : 'Show all'" x-text="inboxFilter === 'all' ? 'All' : 'Unread'"></button>
|
||||
<button type="button" class="sb-panel-action" @click="loadInbox()" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="n in inboxVisible" :key="n.id">
|
||||
<li class="sidebar-item" :class="{ 'sb-item-unread': !n.is_read }" @click="inboxOpen(n)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("bell",14) }}</span>
|
||||
<span style="flex:1;min-width:0;">
|
||||
<span class="page-name" style="display:block;" x-text="n.title"></span>
|
||||
<span class="sb-item-sub" x-text="n.message"></span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!inboxLoading && !inboxVisible.length" style="flex-direction:column;align-items:center;gap:4px;padding:22px 8px;">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-secondary);">Inbox Zero</span>
|
||||
<span class="page-name text-dim">You’re all caught up</span>
|
||||
</li>
|
||||
<li class="sidebar-item empty-hint" x-show="inboxLoading">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<div style="display:flex; gap:6px;">
|
||||
@@ -586,40 +726,40 @@
|
||||
<!-- Section options menu (⋮ dropdown) -->
|
||||
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
|
||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
|
||||
:style="sectionMenuPos()"
|
||||
@click.outside="closeSectionMenu()">
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(5)">
|
||||
<span class="smi-label">Show 5 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(10)">
|
||||
<span class="smi-label">Show 10 items</span>
|
||||
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(15)">
|
||||
<span class="smi-label">Show 15 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(20)">
|
||||
<span class="smi-label">Show 20 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
|
||||
<div class="section-menu-item" @click="closeAndMove('up')">
|
||||
<span class="smi-icon">↑</span>
|
||||
<span class="smi-label">Move up</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
|
||||
<div class="section-menu-item" @click="closeAndMove('down')">
|
||||
<span class="smi-icon">↓</span>
|
||||
<span class="smi-label">Move down</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
|
||||
<div class="section-menu-item" @click="closeAndToggleVisibility()">
|
||||
<span class="smi-icon">👁</span>
|
||||
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
|
||||
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
|
||||
<span class="smi-icon">⚙️</span>
|
||||
<span class="smi-label">Customize sidebar</span>
|
||||
</div>
|
||||
@@ -796,10 +936,6 @@
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// Inject CSRF token into HTMX headers
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
};
|
||||
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRF-Token'] = getCsrf();
|
||||
@@ -853,8 +989,7 @@
|
||||
// these functions for consistent behaviour.
|
||||
window.FlowDeck = {
|
||||
getCsrfToken: function() {
|
||||
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
return getCsrf();
|
||||
},
|
||||
|
||||
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
|
||||
@@ -1123,6 +1258,21 @@
|
||||
}
|
||||
};
|
||||
|
||||
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
|
||||
// (probe : globale window → « Undefined variable: appState »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('appState', appState);
|
||||
});
|
||||
|
||||
/** Parse a SQL/ISO timestamp ("2026-10-04 12:00:00") into a ms epoch.
|
||||
Shared by the sidebar Chat tab (grouping by recency). */
|
||||
window.fdTime = function(value) {
|
||||
if (!value) return 0;
|
||||
var t = new Date(String(value).replace(' ', 'T'));
|
||||
if (isNaN(t.getTime())) t = new Date(value);
|
||||
return isNaN(t.getTime()) ? 0 : t.getTime();
|
||||
};
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
init() {
|
||||
@@ -1130,6 +1280,8 @@
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
this.loadTeamspaces();
|
||||
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
|
||||
this.loadInbox();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
this.startClipAutoRefresh();
|
||||
@@ -1235,6 +1387,176 @@
|
||||
giteaRepo: '{{ gitea_repo|default("") }}',
|
||||
giteaTree: [], // loaded client-side for sidebar
|
||||
currentView: 'home',
|
||||
|
||||
// ── Collapsible sidebar tabs (Home / Chat / Meeting / Inbox) ──
|
||||
// The active tab renders as a pill with its label, the others collapse
|
||||
// to an icon; selecting one replaces the whole sidebar content.
|
||||
sidebarTab: (function() {
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_sidebar_tab');
|
||||
return ['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0 ? saved : 'home';
|
||||
} catch (e) { return 'home'; }
|
||||
})(),
|
||||
setSidebarTab(tab) {
|
||||
this.sidebarTab = tab;
|
||||
this.currentView = tab;
|
||||
try { localStorage.setItem('fd_sidebar_tab', tab); } catch (e) {}
|
||||
if (tab === 'chat') this.loadChatHistory();
|
||||
else if (tab === 'meetings') this.loadMeetings();
|
||||
else if (tab === 'inbox') this.loadInbox();
|
||||
},
|
||||
|
||||
// ── Chat tab ──
|
||||
chatHistory: [],
|
||||
chatLoading: false,
|
||||
get chatRecent() {
|
||||
var cut = Date.now() - 30 * 86400000;
|
||||
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) >= cut; });
|
||||
},
|
||||
get chatOlder() {
|
||||
var cut = Date.now() - 30 * 86400000;
|
||||
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) < cut; });
|
||||
},
|
||||
loadChatHistory() {
|
||||
var self = this;
|
||||
this.chatLoading = true;
|
||||
return fetch('/api/agent/conversations', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
self.chatHistory = (d && d.conversations) || [];
|
||||
self.chatLoading = false;
|
||||
})
|
||||
.catch(function() { self.chatLoading = false; });
|
||||
},
|
||||
chatOpenConversation(id) {
|
||||
if (window.fdAgent && window.fdAgent.openConversation) window.fdAgent.openConversation(id);
|
||||
else this.agentOpen();
|
||||
},
|
||||
chatNewConversation() {
|
||||
if (window.fdAgent && window.fdAgent.open) window.fdAgent.open();
|
||||
else this.agentNew();
|
||||
},
|
||||
|
||||
// ── Meeting tab ──
|
||||
meetings: [],
|
||||
meetingsLoading: false,
|
||||
meetingFilter: 'upcoming',
|
||||
get meetingVisible() {
|
||||
if (this.meetingFilter === 'today') {
|
||||
return this.meetings.filter(function(e) { return e.today; });
|
||||
}
|
||||
return this.meetings.filter(function(e) { return !e.today; });
|
||||
},
|
||||
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
|
||||
loadMeetings(force) {
|
||||
var self = this;
|
||||
if (!force && this.meetings.length) return Promise.resolve();
|
||||
this.meetingsLoading = true;
|
||||
return fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : { events: [] }; })
|
||||
.then(function(d) {
|
||||
self.meetings = (d && d.events) || [];
|
||||
self.meetingsLoading = false;
|
||||
})
|
||||
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
|
||||
},
|
||||
meetingOpen(ev) {
|
||||
var target = ev.url || ('/db/' + ev.collection_id);
|
||||
if (window.fdNavigate) window.fdNavigate(target);
|
||||
else window.location.href = target;
|
||||
},
|
||||
meetingTranscribe() {
|
||||
var input = this.$refs && this.$refs.meetingAudio;
|
||||
if (input) input.click();
|
||||
},
|
||||
meetingNote() {
|
||||
if (window.fdAgent && window.fdAgent.ask) {
|
||||
window.fdAgent.ask('/meeting-note');
|
||||
} else if (window.fdAgent && window.fdAgent.open) {
|
||||
window.fdAgent.open();
|
||||
}
|
||||
},
|
||||
meetingUpload(ev) {
|
||||
var self = this;
|
||||
var input = ev.target;
|
||||
var file = input && input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var title = 'Podcast ' + new Date().toISOString().slice(0, 10);
|
||||
// A transcript row is attached to a page: create the meeting page first,
|
||||
// then upload the audio against it.
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: title, type: 'page' })
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('page'); return r.json(); })
|
||||
.then(function(page) {
|
||||
var fd = new FormData();
|
||||
fd.append('audio', file);
|
||||
fd.append('page_id', String(page.id));
|
||||
return fetch('/api/v2/meetings/transcribe', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRF-Token': self.getCsrfToken() },
|
||||
body: fd
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('transcribe');
|
||||
return r.json();
|
||||
}).then(function() { return page; });
|
||||
})
|
||||
.then(function(page) {
|
||||
self.toast('Audio uploaded — transcription attached to "' + title + '"', 'success');
|
||||
window.location.href = '/pages/' + page.id;
|
||||
})
|
||||
.catch(function() { self.toast('Upload failed', 'error'); })
|
||||
.then(function() { if (input) input.value = ''; });
|
||||
},
|
||||
|
||||
// ── Inbox tab ──
|
||||
inboxItems: [],
|
||||
inboxUnread: 0,
|
||||
inboxLoading: false,
|
||||
inboxFilter: 'all',
|
||||
get inboxVisible() {
|
||||
if (this.inboxFilter === 'unread') {
|
||||
return this.inboxItems.filter(function(n) { return !n.is_read; });
|
||||
}
|
||||
return this.inboxItems;
|
||||
},
|
||||
loadInbox() {
|
||||
var self = this;
|
||||
this.inboxLoading = true;
|
||||
return fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : { notifications: [], unread: 0 }; })
|
||||
.then(function(d) {
|
||||
self.inboxItems = (d && d.notifications) || [];
|
||||
self.inboxUnread = (d && d.unread) || 0;
|
||||
self.inboxLoading = false;
|
||||
})
|
||||
.catch(function() { self.inboxLoading = false; });
|
||||
},
|
||||
inboxOpen(n) {
|
||||
if (!n.is_read) {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ id: n.id })
|
||||
}).then(function() { return self.loadInbox(); });
|
||||
}
|
||||
if (n.url) window.location.href = n.url;
|
||||
},
|
||||
inboxMarkAllRead() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({})
|
||||
}).then(function() { return self.loadInbox(); });
|
||||
},
|
||||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
@@ -1458,6 +1780,10 @@
|
||||
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
||||
},
|
||||
|
||||
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
|
||||
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
|
||||
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
|
||||
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
|
||||
closeSectionMenu() {
|
||||
this.sectionMenu.visible = false;
|
||||
},
|
||||
@@ -1564,6 +1890,55 @@
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
|
||||
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
|
||||
bindProjectIcon(el, p) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
|
||||
},
|
||||
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
|
||||
// scope du topbar = appState) — voir right_actions de page_editor ──
|
||||
edCall(name) {
|
||||
if (window.E && typeof window.E[name] === 'function') window.E[name]();
|
||||
},
|
||||
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
|
||||
edCommentCount() {
|
||||
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
|
||||
},
|
||||
edShared() { return !!(window.E && window.E.pageIsShared); },
|
||||
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
|
||||
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
|
||||
|
||||
bindAgentIcon(el, a) {
|
||||
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
|
||||
},
|
||||
syncSidebarClass() {
|
||||
document.documentElement.classList.toggle(
|
||||
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
|
||||
},
|
||||
initSidebarSort() {
|
||||
Alpine.nextTick(() => {
|
||||
if (typeof initTreeSortable === 'function') initTreeSortable();
|
||||
});
|
||||
},
|
||||
fdCreatePage() { window.FlowDeck.createPage(); },
|
||||
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||
sidebarSections() {
|
||||
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
|
||||
return {
|
||||
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
|
||||
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
|
||||
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
|
||||
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
|
||||
};
|
||||
},
|
||||
sectionMenuPos() {
|
||||
return {
|
||||
top: this.sectionMenu.y + 'px',
|
||||
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
@@ -1718,9 +2093,16 @@
|
||||
case 'openTab':
|
||||
window.open('/' + pageId, '_blank');
|
||||
break;
|
||||
case 'openPeek':
|
||||
this.toast('Side peek not available in browser', 'info');
|
||||
case 'openPeek': {
|
||||
// Route vers le panneau de peek DE LA PAGE courante (même
|
||||
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
|
||||
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
|
||||
var num = pageId.replace('page/', '');
|
||||
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
|
||||
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
|
||||
else window.open('/pages/' + num, '_blank');
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.contextMenu.visible = false;
|
||||
},
|
||||
@@ -1733,8 +2115,7 @@
|
||||
addPage(section) { this.newPage(section); },
|
||||
addSubPage(id) { this.newSubPage(id); },
|
||||
getCsrfToken() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
return getCsrf();
|
||||
},
|
||||
newPage(section) {
|
||||
const project = this.workspaceKey || '';
|
||||
@@ -2181,6 +2562,14 @@
|
||||
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
||||
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
||||
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
|
||||
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
|
||||
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
|
||||
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
|
||||
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
|
||||
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
|
||||
a.cp-cite:hover{text-decoration:underline}
|
||||
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
|
||||
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
||||
@@ -2202,6 +2591,10 @@
|
||||
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
||||
placeholder="Search pages, databases, or type a command…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
|
||||
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
|
||||
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
|
||||
</div>
|
||||
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
||||
@@ -2222,7 +2615,7 @@
|
||||
];
|
||||
|
||||
var overlay, input, list;
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function highlight(text, q){
|
||||
@@ -2246,6 +2639,7 @@
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(state.tab==='ai'){ renderAi(); return; }
|
||||
if(!state.open) return;
|
||||
if(!state.items.length){
|
||||
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
||||
@@ -2282,6 +2676,7 @@
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
if(state.tab==='ai'){ runAi(); return; }
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
@@ -2292,7 +2687,7 @@
|
||||
return;
|
||||
}
|
||||
state.actions = false;
|
||||
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
|
||||
fetch('/api/search?q='+encodeURIComponent(q))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if(input.value.trim()!==q) return; // stale
|
||||
@@ -2306,6 +2701,50 @@
|
||||
.catch(function(){ state.items=[]; render(); });
|
||||
}
|
||||
|
||||
function runAi(){
|
||||
var q = input.value.trim();
|
||||
state.ai = null;
|
||||
if(!q){ state.ai = {hint:1}; renderAi(); return; }
|
||||
state.ai = {loading:1}; renderAi();
|
||||
fetch('/api/v2/search/ask', {
|
||||
method:'POST',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
||||
body: JSON.stringify({question: q})
|
||||
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
|
||||
.then(function(d){
|
||||
if(state.tab!=='ai' || input.value.trim()!==q) return;
|
||||
state.ai = d; renderAi();
|
||||
})
|
||||
.catch(function(){
|
||||
if(state.tab!=='ai') return;
|
||||
state.ai = {error:1}; renderAi();
|
||||
});
|
||||
}
|
||||
function renderAi(){
|
||||
var a = state.ai;
|
||||
if(!a){ list.innerHTML=''; return; }
|
||||
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
|
||||
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
|
||||
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
|
||||
var cits = a.citations || [], byId = {};
|
||||
cits.forEach(function(c){ byId[String(c.id)] = c; });
|
||||
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
|
||||
var md = esc(a.answer_markdown || '')
|
||||
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
|
||||
var c = byId[id];
|
||||
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
|
||||
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
||||
var cites = cits.map(function(c){
|
||||
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
|
||||
}).join('');
|
||||
list.innerHTML = '<div class="cmd-palette-ai">' + md +
|
||||
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
|
||||
}
|
||||
|
||||
function open(){
|
||||
if(state.open) return;
|
||||
state.open=true; overlay.classList.add('open');
|
||||
@@ -2360,6 +2799,16 @@
|
||||
list = document.getElementById('fd-cp-list');
|
||||
if(!overlay) return;
|
||||
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
||||
var tabs = document.getElementById('fd-cp-tabs');
|
||||
if(tabs) tabs.addEventListener('click', function(e){
|
||||
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
|
||||
if(!b) return;
|
||||
state.tab = b.getAttribute('data-tab');
|
||||
var all = tabs.querySelectorAll('.cp-tab');
|
||||
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
|
||||
state.index = 0;
|
||||
runSearch();
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<template x-for="(f, i) in activeFilters" :key="i">
|
||||
<div class="filter-pill">
|
||||
<span x-text="f.property + ': ' + f.value"></span>
|
||||
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
|
||||
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
|
||||
@click.stop>
|
||||
<template x-for="s in statusOptions" :key="s.value">
|
||||
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
|
||||
<div class="dropdown-option" @click="pickStatus(s.value)">
|
||||
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
|
||||
<span x-show="statusFilters.includes(s.value)">✓</span>
|
||||
</span>
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
|
||||
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
|
||||
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
|
||||
|
||||
@@ -49,12 +49,5 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
});
|
||||
document.getElementById('card-modal').style.display = 'flex';
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
</script>
|
||||
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
|
||||
corps identique x2 dans ces deux fragments de vue) -->
|
||||
|
||||
@@ -95,17 +95,17 @@
|
||||
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
||||
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
||||
resize:vertical; margin-top:8px;"
|
||||
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
|
||||
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// ponytail: CSRF helper
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -139,6 +139,7 @@
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
},
|
||||
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
|
||||
addComment(body) {
|
||||
if (!body.trim()) return;
|
||||
console.log('Add comment:', body);
|
||||
|
||||
@@ -58,12 +58,5 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
});
|
||||
document.getElementById('card-modal').style.display = 'flex';
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
</script>
|
||||
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
|
||||
corps identique x2 dans ces deux fragments de vue) -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -120,7 +120,7 @@
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
|
||||
+21
-15
@@ -18,7 +18,7 @@
|
||||
|
||||
<style>
|
||||
/* ── Library layout ── */
|
||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
/* .lib-container est défini dans app.css (largeur standard des pages) */
|
||||
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
|
||||
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
||||
@@ -150,7 +150,7 @@
|
||||
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
|
||||
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.peek-header button svg{width:15px;height:15px;}
|
||||
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
|
||||
.peek-body{flex:1;overflow:hidden;padding:0;}
|
||||
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
</div>
|
||||
<div class="lib-toolbar">
|
||||
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
|
||||
@@ -245,7 +245,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
@@ -255,11 +255,11 @@
|
||||
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||
@@ -310,7 +310,7 @@
|
||||
<button @click="copyLinks()" title="Copy links">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
|
||||
<button @click="openMoveSelected()" title="Move to">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<div style="position:relative;margin-left:auto;">
|
||||
@@ -326,7 +326,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy links to all
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
|
||||
<div class="menu-item" @click="openMoveSelected()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div class="lib-empty" id="lib-empty">
|
||||
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
|
||||
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -408,13 +408,13 @@
|
||||
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
||||
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
|
||||
<template x-if="peekItem">
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div style="flex-shrink:0;">
|
||||
<div class="peek-header">
|
||||
<button @click="closePeek()" title="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-init="bindHtmlItem($el, peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
@@ -445,11 +445,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
||||
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
|
||||
permanence, ce qui permet de piloter `src` en JS (le build CSP
|
||||
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
|
||||
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
|
||||
document en mode edition, sans sidebar ni barre de navigation. -->
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||||
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
|
||||
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /x-data -->
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
|
||||
<div x-data="_wsInitData"
|
||||
<div x-data="wsInitData()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@@ -482,10 +482,10 @@
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@contextmenu.prevent="onContextMenu($event)"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($event)"
|
||||
@touchmove="onTouchMove($event)"
|
||||
@@ -553,7 +553,7 @@
|
||||
<div class="ws-toolbar" style="margin-left:auto;">
|
||||
<!-- View dropdown -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
@@ -567,7 +567,7 @@
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
@@ -581,7 +581,7 @@
|
||||
</div>
|
||||
<!-- Filter -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
@@ -597,7 +597,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search toggle -->
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<!-- Expand/Collapse (tree only) -->
|
||||
@@ -650,12 +650,12 @@
|
||||
|
||||
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
</template>
|
||||
|
||||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="icon" x-init="bindFileIcon($el, node)"></span>
|
||||
|
||||
<template x-if="node.is_folder">
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@@ -754,8 +754,8 @@
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -765,7 +765,7 @@
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
x-init="bindChildren($el, node)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -807,7 +807,7 @@
|
||||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||||
<td>
|
||||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -864,7 +864,7 @@
|
||||
</tr>
|
||||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||||
@@ -902,7 +902,7 @@
|
||||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td class="col-path" x-text="node._path || '—'"></td>
|
||||
@@ -933,7 +933,7 @@
|
||||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
|
||||
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
|
||||
<div class="title-card-name">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-text="node.name"></span>
|
||||
@@ -960,7 +960,7 @@
|
||||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<div class="content-item-header">
|
||||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" x-text="node.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
|
||||
<p class="modal-sub">
|
||||
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
|
||||
</p>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
|
||||
<div class="modal-actions">
|
||||
@@ -1037,7 +1037,7 @@
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("edit",14) }} Rename</h3>
|
||||
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
||||
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("trash",16) }} Delete</h3>
|
||||
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
||||
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
||||
@@ -1065,11 +1065,14 @@
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
|
||||
pré-existante, masquée par le fallback window d'Alpine standard) →
|
||||
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
|
||||
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
<div class="file-preview-header" x-text="previewName"></div>
|
||||
<div class="file-preview-body" x-html="previewContent"></div>
|
||||
<div class="file-preview-body" x-init="bindPreview($el)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel — identical to Library peek-overlay -->
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
<h1 class="database-page-title">{{ page.title }}</h1>
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
|
||||
@@ -8,4 +8,9 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% include '_page_editor_scripts.html' %}
|
||||
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
|
||||
include, une base embarquée dans un document restait bloquée à
|
||||
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
|
||||
faisait rien. #}
|
||||
{% include '_database_table_scripts.html' %}
|
||||
{% endblock %}
|
||||
|
||||
+146
-20
@@ -127,7 +127,7 @@
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
@@ -136,24 +136,24 @@
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -515,9 +515,9 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
|
||||
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
|
||||
</span>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
|
||||
<input type="text" class="settings-input" x-model="renameValue"
|
||||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||
style="flex:1;" autofocus>
|
||||
@@ -635,16 +635,142 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Actions (JSON)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline visuel (steps API v7.0) -->
|
||||
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Pipeline visuel (steps)</div>
|
||||
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
|
||||
<template x-for="(s, i) in editSteps" :key="s.id">
|
||||
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
|
||||
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
|
||||
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
|
||||
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
|
||||
</div>
|
||||
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
|
||||
<template x-if="s.kind==='trigger'">
|
||||
<div>
|
||||
<div class="setting-label">Événement</div>
|
||||
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
|
||||
<datalist id="auto-events">
|
||||
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
|
||||
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
|
||||
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='condition'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<select class="settings-input" x-model="s.config.op">
|
||||
<option value="eq">=</option><option value="neq">≠</option>
|
||||
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
|
||||
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
|
||||
<option value="changed">changé</option>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='delay'">
|
||||
<div>
|
||||
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
|
||||
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='action'">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<select class="settings-input" x-model="s.config.type">
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="set_property">Définir une propriété</option>
|
||||
<option value="create_page">Créer une page</option>
|
||||
<option value="notify">Notification</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="forge_issue">Issue Gitea/GitHub</option>
|
||||
<option value="agent_trigger">Déclencher un agent</option>
|
||||
</select>
|
||||
<template x-if="s.config.type==='webhook'">
|
||||
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
|
||||
</template>
|
||||
<template x-if="s.config.type==='set_property'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='create_page'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="settings-input" x-model="s.config.collection_id">
|
||||
<option value="">Base (optionnel)</option>
|
||||
<template x-for="c in globalCollections" :key="c.id">
|
||||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='notify'">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</template>
|
||||
<template x-if="s.config.type==='slack'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
|
||||
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='email'">
|
||||
<div style="display:flex;gap:8px;flex-direction:column;">
|
||||
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
|
||||
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
|
||||
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='forge_issue'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
|
||||
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='agent_trigger'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
|
||||
<option value="trigger">Déclencheur</option>
|
||||
<option value="condition">Condition</option>
|
||||
<option value="delay">Attente</option>
|
||||
<option value="action">Action</option>
|
||||
</select>
|
||||
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
|
||||
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
|
||||
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||
@@ -770,11 +896,11 @@
|
||||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||
<td>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -813,7 +939,7 @@
|
||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
|
||||
</template>
|
||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
@@ -837,7 +963,7 @@
|
||||
<tbody>
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
@@ -853,7 +979,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
|
||||
</div>
|
||||
<style>
|
||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('tableView', tableView); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
+273
-33
@@ -11,46 +11,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
<div x-data="trashData()" class="lib-container">
|
||||
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||
<div style="position:relative; flex:1;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
|
||||
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
|
||||
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emplacement -->
|
||||
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
|
||||
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
|
||||
{{ fd_icon("folder",16) }}
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
|
||||
<div class="dd-label">Location</div>
|
||||
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
|
||||
<template x-for="l in locations" :key="l">
|
||||
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge totale -->
|
||||
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
</button>
|
||||
<!-- Sélection + compteur + actions groupées -->
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
|
||||
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
|
||||
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
|
||||
</label>
|
||||
<span x-text="countLabel"></span>
|
||||
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
|
||||
<button class="btn-sm" @click="restoreSelected()">Restore</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
|
||||
<button class="btn-sm" @click="clearSel()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
|
||||
:checked="selected.includes(item.id)" @change="toggle(item.id)">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
<span class="trash-item-path">
|
||||
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
||||
|
||||
<!-- État vide : aucun contenu -->
|
||||
<div x-show="items.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
|
||||
<!-- État vide : filtres sans résultat -->
|
||||
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
|
||||
<h3>No pages match</h3>
|
||||
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
|
||||
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
@@ -58,37 +106,229 @@
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
|
||||
flowdeck-modal définies dans base.html, thème variables du site -->
|
||||
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
|
||||
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
|
||||
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
|
||||
<div class="flowdeck-modal-header">
|
||||
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
|
||||
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="flowdeck-modal-body">
|
||||
<p x-text="confirm.message"></p>
|
||||
</div>
|
||||
<div class="flowdeck-modal-footer">
|
||||
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
|
||||
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
|
||||
function trashData() {
|
||||
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
||||
var RETENTION_DAYS = 30;
|
||||
return {
|
||||
search: '',
|
||||
sort: 'recent',
|
||||
locFilter: 'all',
|
||||
sortOpen: false,
|
||||
locOpen: false,
|
||||
selected: [],
|
||||
items: [],
|
||||
// Confirmation thématique (remplace confirm() du navigateur)
|
||||
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
|
||||
|
||||
async init() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const token = csrf ? csrf[1] : '';
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
} catch (e) {
|
||||
this.items = [];
|
||||
window.showToast('Could not load the Trash: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
get locations() {
|
||||
const set = {};
|
||||
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
|
||||
return Object.keys(set).sort();
|
||||
},
|
||||
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
const loc = this.locFilter;
|
||||
const out = this.items.filter(function (i) {
|
||||
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
|
||||
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
|
||||
});
|
||||
const key = function (i) { return i.deleted_at || ''; };
|
||||
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
|
||||
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
|
||||
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||
return out;
|
||||
},
|
||||
|
||||
get countLabel() {
|
||||
const n = this.filteredItems.length, t = this.items.length;
|
||||
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
|
||||
},
|
||||
|
||||
get allSelected() {
|
||||
const list = this.filteredItems;
|
||||
const self = this;
|
||||
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
|
||||
},
|
||||
|
||||
toggle(id) {
|
||||
const i = this.selected.indexOf(id);
|
||||
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
|
||||
},
|
||||
toggleAll() {
|
||||
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
|
||||
},
|
||||
clearSel() { this.selected = []; },
|
||||
clearFilters() { this.search = ''; this.locFilter = 'all'; },
|
||||
toggleSort() { this.sortOpen = !this.sortOpen; },
|
||||
toggleLoc() { this.locOpen = !this.locOpen; },
|
||||
setSort(v) { this.sort = v; this.sortOpen = false; },
|
||||
setLoc(v) { this.locFilter = v; this.locOpen = false; },
|
||||
|
||||
// ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ──
|
||||
// Mutations de propriétés individuelles (remplacer l'objet entier tue la
|
||||
// réactivité Alpine — pitfall connu).
|
||||
askConfirm(title, message, okText, action) {
|
||||
this.confirm.title = title;
|
||||
this.confirm.message = message;
|
||||
this.confirm.okText = okText || 'Delete';
|
||||
this.confirm.action = action;
|
||||
this.confirm.show = true;
|
||||
},
|
||||
cancelConfirm() {
|
||||
this.confirm.show = false;
|
||||
this.confirm.action = null;
|
||||
},
|
||||
acceptConfirm() {
|
||||
const action = this.confirm.action;
|
||||
this.confirm.show = false;
|
||||
this.confirm.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────
|
||||
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
|
||||
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
|
||||
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
|
||||
_drop(ids) {
|
||||
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
|
||||
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
|
||||
},
|
||||
|
||||
async _restore(id) {
|
||||
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
async _delete(id) {
|
||||
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
|
||||
async restore(id) {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
try {
|
||||
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
||||
else window.showToast('Restore failed', 'error');
|
||||
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
async restoreSelected() {
|
||||
const ids = this.selected.slice();
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
|
||||
},
|
||||
async _deleteAndToast(ids) {
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
const msg = ids.length === 1
|
||||
? (ok ? 'Page permanently deleted' : 'Delete failed')
|
||||
: ok + ' of ' + ids.length + ' page(s) deleted';
|
||||
window.showToast(msg, ok === ids.length ? 'success' : 'error');
|
||||
},
|
||||
deleteForever(id) {
|
||||
const self = this;
|
||||
const item = this.items.find(function (i) { return i.id === id; });
|
||||
const name = item ? item.name : 'this page';
|
||||
this.askConfirm('Delete permanently',
|
||||
'“' + name + '” will be deleted for good. This cannot be undone.',
|
||||
'Delete',
|
||||
function () { self._deleteAndToast([id]); });
|
||||
},
|
||||
deleteSelected() {
|
||||
const self = this;
|
||||
const ids = this.selected.slice();
|
||||
this.askConfirm('Delete ' + ids.length + ' page(s) for good?',
|
||||
'These pages will be permanently deleted. This cannot be undone.',
|
||||
'Delete all',
|
||||
function () { self._deleteAndToast(ids); });
|
||||
},
|
||||
async emptyTrash() {
|
||||
const self = this;
|
||||
const n = this.items.length;
|
||||
this.askConfirm('Empty Trash?',
|
||||
'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.',
|
||||
'Empty Trash',
|
||||
function () { self._emptyAndToast(n); });
|
||||
},
|
||||
async _emptyAndToast(n) {
|
||||
try {
|
||||
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = [];
|
||||
this.selected = [];
|
||||
window.showToast(n + ' page(s) purged', 'success');
|
||||
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
|
||||
_ts(s) {
|
||||
if (!s) return null;
|
||||
let t = String(s).replace(' ', 'T');
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
|
||||
const d = new Date(t);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
},
|
||||
ago(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return 'Deleted';
|
||||
const sec = (Date.now() - d.getTime()) / 1000;
|
||||
if (sec < 60) return 'Deleted just now';
|
||||
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
|
||||
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
|
||||
const days = Math.floor(sec / 86400);
|
||||
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
||||
},
|
||||
leftLabel(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return '';
|
||||
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (left <= 0) return ' · auto-delete soon';
|
||||
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -134,6 +134,12 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user