fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
@@ -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:":
|
||||
|
||||
+3
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.45.6",
|
||||
version="7.49.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)
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -485,6 +485,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
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 = []
|
||||
|
||||
@@ -12,6 +12,11 @@ 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
|
||||
@@ -176,10 +181,15 @@ def restore_page(request: Request, page_id: int):
|
||||
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}
|
||||
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
@@ -193,13 +203,23 @@ def empty_trash(request: Request):
|
||||
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}
|
||||
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
# ═══════════════ 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 = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
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 = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
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
|
||||
@@ -86,6 +105,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
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)
|
||||
@@ -99,8 +119,9 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
def get_synced_block_api(sid: int):
|
||||
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:
|
||||
|
||||
@@ -17,11 +17,13 @@ from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
index_page,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
task_db,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
|
||||
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,
|
||||
|
||||
@@ -8,10 +8,11 @@ import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
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
|
||||
|
||||
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
collections = [dict(r) for r in rows]
|
||||
return HTMLResponse(
|
||||
f"<div class='collections-list'>"
|
||||
f"<h2>Collections ({len(collections)})</h2>"
|
||||
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
||||
f"</div>"
|
||||
)
|
||||
#
|
||||
# `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.
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
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"))
|
||||
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
||||
(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
|
||||
|
||||
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
# `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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -211,14 +211,25 @@ def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
|
||||
@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)."""
|
||||
"""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")
|
||||
new_val = 0 if coll["is_task"] else 1
|
||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -174,6 +174,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
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
|
||||
@@ -199,8 +204,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
# 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:
|
||||
@@ -209,18 +215,18 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
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 local_ws_id:
|
||||
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",
|
||||
(local_ws_id,)
|
||||
(gitea_mirror_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -34,10 +35,16 @@ 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):
|
||||
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
|
||||
@@ -46,13 +53,46 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws = _get_active_workspace(request, user_id=user["id"])
|
||||
ws_id = ws["id"] if ws else None
|
||||
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
|
||||
@@ -63,7 +103,9 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
||||
"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,
|
||||
@@ -255,6 +297,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
@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")
|
||||
@@ -279,6 +322,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
|
||||
@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))
|
||||
@@ -291,6 +335,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
|
||||
@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(
|
||||
@@ -306,6 +351,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
|
||||
@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=?",
|
||||
@@ -342,6 +388,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
||||
@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(
|
||||
|
||||
@@ -6,18 +6,47 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
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, _sidebar_data
|
||||
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)
|
||||
@@ -42,8 +71,7 @@ def workspaces_page(request: Request):
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
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 "
|
||||
@@ -67,7 +95,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
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()
|
||||
@@ -99,6 +127,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
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()
|
||||
@@ -109,13 +138,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
"""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"}
|
||||
return {"status": "ok", "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +158,7 @@ def delete_workspace(request: Request, ws_id: int):
|
||||
@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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -125,13 +125,21 @@
|
||||
<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>
|
||||
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) -->
|
||||
<div id="db-side-peek" class="db-side-peek">
|
||||
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" 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="db-side-peek-title" id="db-side-peek-title"></div>
|
||||
<button id="db-side-peek-full" title="Open full page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-side-peek-body">
|
||||
<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>
|
||||
<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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)}
|
||||
@@ -189,7 +196,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 +218,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">
|
||||
|
||||
+324
-7
@@ -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) ── */
|
||||
@@ -241,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')">
|
||||
@@ -571,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;">
|
||||
@@ -1128,6 +1264,15 @@
|
||||
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() {
|
||||
@@ -1135,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();
|
||||
@@ -1240,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,
|
||||
|
||||
@@ -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;}
|
||||
@@ -408,7 +408,7 @@
|
||||
<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>
|
||||
@@ -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}` sans `?embed=1` : c'est exactement l'editeur
|
||||
plein ecran, le peek n'est plus une variante allegee. -->
|
||||
<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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<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;">
|
||||
|
||||
+38
-1
@@ -6,10 +6,46 @@ les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from contextvars import ContextVar
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from jinja2 import Environment, FileSystemLoader, Undefined, select_autoescape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoggingUndefined(Undefined):
|
||||
"""Undefined qui se rend comme une chaîne vide mais **journalise**.
|
||||
|
||||
v7.46.0 : avec l'`Undefined` par défaut, une variable mal orthographiée
|
||||
(ou absente du contexte de la route) rendait `""` sans aucune trace — c'est
|
||||
exactement ce qui a rendu le bug du bouton Home invisible (``local_workspaces``
|
||||
absent ⇒ `/workspaces`` en silence). ``StrictUndefined`` est inutilisable ici
|
||||
(40+ templates optionnelles, ex. ``embed_mode``), donc on garde le rendu et
|
||||
on rend le silence visible dans les logs.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def _warn(self) -> None:
|
||||
logger.warning("Jinja: variable non définie utilisée dans un template: %r", self._undefined_name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
self._warn()
|
||||
return ""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
self._warn()
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
self._warn()
|
||||
return iter(())
|
||||
|
||||
def __len__(self) -> int:
|
||||
self._warn()
|
||||
return 0
|
||||
|
||||
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
||||
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
||||
@@ -25,6 +61,7 @@ CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
undefined=LoggingUndefined,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user