Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e59009c3 | ||
|
|
8d0d69e7b8 | ||
|
|
103bc57418 | ||
|
|
45917c194d |
+114
@@ -1,5 +1,119 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en
|
||||
threadpool, avant : 61 %) en 4 passes :
|
||||
· **Racine auth** : `get_current_user` (session.py) était `async def`
|
||||
**sans aucun await** (cookie decode = synchrone) ; idem ses clones
|
||||
(`agent._current_user_id/_workspace_id/_current_admin`,
|
||||
`sso._require_admin` au corps 0 await) → `def` + **47 `await`
|
||||
supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne
|
||||
les voyait pas, la suite les a attrapés)
|
||||
· Re-scan : 19 routes devenues sans await → `def`
|
||||
· **155 routes** dont les seuls awaits étaient `request.json()` /
|
||||
événements → `Body(default={})` + `run_event_sync(...)` puis `def` :
|
||||
- try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte)
|
||||
- try/except `raise HTTPException(400)` → `Body(...)` **requis**
|
||||
(422 FastAPI — **aucun test ne couvrait le 400**, aucun call front
|
||||
n'envoie de JSON invalide)
|
||||
- forme conditionnelle `request.json() if content-type else {}`
|
||||
(54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas
|
||||
- Import `Body`/`run_event_sync` ajoutés aux routers convertis
|
||||
|
||||
### Notes
|
||||
|
||||
- **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read`
|
||||
(corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body`
|
||||
(9 — wrapper de validation), 2 JSON inline en argument d'appel,
|
||||
1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle
|
||||
de taille (web_clipper)
|
||||
- Suite complète : **1089/1089** · ruff OK
|
||||
|
||||
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Toasts settings jamais affichés** : `settings.js` appelait
|
||||
`typeof toast === 'function'` — guard **toujours faux** (le `toast` global
|
||||
n'existait pas) → les retours « Timezone saved » / « SP metadata URL
|
||||
copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`)
|
||||
- `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré,
|
||||
sans effet observable car `window._wsInitData` existe depuis L4) supprimé
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0,
|
||||
22 fichiers) :
|
||||
· no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }`
|
||||
(binding optionnel ES2019 ; zéro changement de comportement)
|
||||
· no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes
|
||||
déterministes (assert sur texte exact avant suppression) +
|
||||
`/* exported */` sur les 10 fonctions appelées depuis les attributs
|
||||
HTML des templates (vérifiées par grep)
|
||||
· no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs`
|
||||
(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
|
||||
|
||||
### Notes
|
||||
|
||||
- `node --check` vert sur tous les fichiers ; suite complète **1089/1089**
|
||||
- A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par
|
||||
nature, décision assumée) + ~500 L de petits blocs hors cibles
|
||||
|
||||
## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites**
|
||||
vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la
|
||||
construction de l'objet de config (4 clés + `{% if collection_data %}`) →
|
||||
config JSON `#db-config` **null-vs-objet** : `new DBInstance(container,
|
||||
PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja
|
||||
- 2 tests adaptés (lisaient le template source → `database_table.js`) ;
|
||||
`FlowDeckDB` plus dans le HTML → assert sur le JS
|
||||
|
||||
### Notes
|
||||
|
||||
- **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 +
|
||||
1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`,
|
||||
`node --check` vert partout, eslint **0 erreur / 285 warnings**
|
||||
- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste
|
||||
inline par nature, décision assumée), ~500 L de petits blocs hors cibles,
|
||||
nettoyage des 285 warnings
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
|
||||
JSON (identique à la 2a) :
|
||||
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
|
||||
current_folder_id, workspace_id)
|
||||
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
|
||||
avatar_color, user full_name/login/email, is_admin, auth_method —
|
||||
**2 routes rendent ce template**, les expressions `or ""` sont préservées
|
||||
pour les valeurs Undefined, `is_admin` reste un booléen)
|
||||
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
|
||||
`rt-config` : SELF id/login/full_name/color)
|
||||
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
|
||||
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
|
||||
explicite) au lieu d'être interpolées dans des strings JS
|
||||
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
|
||||
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
|
||||
des fichiers extraits (try/catch → `{}`)
|
||||
|
||||
### Notes
|
||||
|
||||
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
|
||||
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
|
||||
(12 fichiers, baseline mise à jour)
|
||||
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
|
||||
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
||||
|
||||
### Changed
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.22.0 (audit — A27 phase 2 : éditeur 2 516 L via page-data JSON) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.26.0 (audit — A21 phase 2c : 190 routes hors loop, 86 % total) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ def _touch_session(sid: str) -> None:
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.22.0",
|
||||
version="7.26.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+4
-12
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
@@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
@@ -46,15 +46,11 @@ def list_users(_admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
@@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
|
||||
+58
-68
@@ -9,7 +9,7 @@ import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.session import get_current_user
|
||||
@@ -93,16 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int:
|
||||
def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
def _workspace_id(request: Request) -> int | None:
|
||||
user = get_current_user(request)
|
||||
if user and user.get("workspace_id"):
|
||||
return user["workspace_id"]
|
||||
try:
|
||||
@@ -112,11 +112,11 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
def _current_admin(request: Request) -> dict:
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
@@ -146,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def list_agents(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user_id)
|
||||
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
|
||||
@@ -156,10 +156,9 @@ async def list_agents(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_agent(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_agent(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
@@ -184,8 +183,8 @@ async def create_agent(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
def list_conversations(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
|
||||
@@ -195,10 +194,9 @@ async def list_conversations(request: Request):
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_conversation(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
ws = await _workspace_id(request)
|
||||
def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
cur = conn.execute(
|
||||
@@ -236,10 +234,9 @@ def delete_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def patch_conversation(request: Request, conversation_id: int):
|
||||
def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
"""Update a conversation's title / provider / model (slash-command support)."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute(
|
||||
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
@@ -262,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/run")
|
||||
async def run_conversation(request: Request, conversation_id: int):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
objective = (body.get("message") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(status_code=400, detail="message est requis")
|
||||
@@ -323,7 +319,7 @@ async def agent_generate(request: Request):
|
||||
Because no tool schema is offered, the model answers with plain text based on
|
||||
the provided document context instead of issuing search_workspace / tools.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prompt = (body.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
@@ -383,7 +379,7 @@ async def agent_writing(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
@@ -422,7 +418,7 @@ async def agent_writing_properties(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
properties = body.get("properties") or []
|
||||
if not isinstance(properties, list) or not properties:
|
||||
@@ -474,18 +470,17 @@ def undo(request: Request, action_id: int):
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills(request: Request):
|
||||
ws = await _workspace_id(request)
|
||||
def list_skills(request: Request):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
|
||||
return {"skills": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_skill(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
@@ -504,10 +499,10 @@ async def create_skill(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill(request: Request, skill_id: int):
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
@@ -535,13 +530,12 @@ def skills_gallery(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
@@ -555,11 +549,10 @@ async def install_gallery_skill(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
def import_skill(request: Request, body: dict = Body(default={})):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
@@ -601,7 +594,7 @@ def delete_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.get("/mentions")
|
||||
async def list_mentions(request: Request, q: str = ""):
|
||||
def list_mentions(request: Request, q: str = ""):
|
||||
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
|
||||
|
||||
Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
|
||||
@@ -624,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
except (TypeError, ValueError):
|
||||
ws = None
|
||||
if not ws:
|
||||
ws = await _workspace_id(request)
|
||||
ws = _workspace_id(request)
|
||||
|
||||
def dedupe(items: list[dict]) -> list[dict]:
|
||||
seen: set = set()
|
||||
@@ -729,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def add_feedback(request: Request):
|
||||
def add_feedback(request: Request, body: dict = Body(default={})):
|
||||
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
rating = (body.get("rating") or "").strip().lower()
|
||||
if rating not in ("up", "down"):
|
||||
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
|
||||
@@ -766,8 +758,8 @@ async def add_feedback(request: Request):
|
||||
async def trigger_agent(request: Request, agent_id: int):
|
||||
"""Manually fire a custom agent: create a conversation and run it with the
|
||||
agent's instructions as the objective (falls back to a generic prompt)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
@@ -814,7 +806,7 @@ async def list_providers(request: Request):
|
||||
- ``verified`` : the last connection test / model fetch succeeded.
|
||||
- ``functional`` : the provider is ready to chat (verified, or `offline`).
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
llm = LLMClient()
|
||||
cfg = get_llm_config()
|
||||
keys = list_user_llm_keys(user_id)
|
||||
@@ -869,20 +861,19 @@ async def list_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_llm_keys(request: Request):
|
||||
def list_llm_keys(request: Request):
|
||||
"""The user's saved provider keys + API keys (masked)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
return {"keys": list_user_llm_keys(user_id)}
|
||||
|
||||
|
||||
@router.put("/keys/{llm_provider}")
|
||||
async def save_llm_key(request: Request, llm_provider: str):
|
||||
def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})):
|
||||
"""Upsert a provider key for the current user (masked in responses)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
api_base_raw = body.get("api_base")
|
||||
raw = upsert_user_llm_key(
|
||||
user_id,
|
||||
@@ -898,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str):
|
||||
|
||||
|
||||
@router.delete("/keys/{llm_provider}")
|
||||
async def delete_llm_key(request: Request, llm_provider: str):
|
||||
def delete_llm_key(request: Request, llm_provider: str):
|
||||
"""Remove a saved provider key for the current user."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -915,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str):
|
||||
On success the provider is flagged ``verified`` so it can be offered in the
|
||||
Agent panel; on failure the stored error is kept for display in Settings.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -970,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
A successful fetch proves connectivity, so when it used the *stored* key the
|
||||
provider is flagged ``verified`` (functional) for the Agent panel.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -1019,7 +1010,7 @@ def _check_api_base(value: str) -> str:
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower()
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1049,7 +1040,7 @@ async def test_provider_config(request: Request):
|
||||
A successful test flags the workspace default provider as ``verified`` so it
|
||||
becomes available (functional) for every user in the Agent panel.
|
||||
"""
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower() or None
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1090,8 +1081,7 @@ def get_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.put("/{agent_id}")
|
||||
async def update_agent(request: Request, agent_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_agent(request: Request, agent_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
|
||||
+8
-24
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -220,15 +220,11 @@ def login(request: Request, provider: str = Query("gitea")):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
def register(request: Request, body: dict = Body(default={})):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
@@ -277,7 +273,7 @@ async def register(request: Request):
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
def local_login(request: Request, body: dict = Body(default={})):
|
||||
"""Login with email + password."""
|
||||
import time
|
||||
|
||||
@@ -285,10 +281,6 @@ async def local_login(request: Request):
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
@@ -410,7 +402,7 @@ async def callback(
|
||||
oauth_mode = request.session.pop("oauth_mode", "")
|
||||
if oauth_mode == "link":
|
||||
from app.auth.session import get_current_user as gcu
|
||||
current = await gcu(request)
|
||||
current = gcu(request)
|
||||
if not current:
|
||||
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||
from app.db import get_conn as _gc
|
||||
@@ -480,10 +472,10 @@ def logout(request: Request):
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def current_user(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
@@ -492,16 +484,12 @@ async def current_user(request: Request):
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
def local_verify(request: Request, body: dict = Body(default={})):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
@@ -544,15 +532,11 @@ def twofa_setup(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
def twofa_activate(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -79,8 +79,7 @@ def list_automations(request: Request):
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_automation(request: Request, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user["id"]
|
||||
@@ -118,8 +117,7 @@ def get_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_automation(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
@@ -233,11 +231,10 @@ def list_steps(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
def create_step(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
@@ -256,9 +253,8 @@ async def create_step(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
def update_step(request: Request, step_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -288,12 +284,11 @@ def delete_step(request: Request, step_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
|
||||
+45
-83
@@ -6,7 +6,7 @@ import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -14,7 +14,7 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
@@ -124,16 +124,12 @@ def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
@@ -146,21 +142,17 @@ async def set_page_lock(request: Request, page_id: int):
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]})
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
async def set_page_options(request: Request, page_id: int):
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
@@ -199,16 +191,12 @@ def list_page_templates_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -239,17 +227,13 @@ async def create_page_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
@@ -331,8 +315,8 @@ async def use_page_template(request: Request, template_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@@ -955,7 +939,7 @@ def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -973,14 +957,14 @@ async def add_favorite(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_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": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -988,7 +972,7 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
@@ -996,9 +980,8 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
@@ -1011,22 +994,22 @@ async def update_share(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1040,12 +1023,12 @@ def list_trash(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
@@ -1080,12 +1063,8 @@ def list_synced_blocks_api(request: Request, workspace: str = Query(default=""))
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
async def create_synced_block_api(request: Request):
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
@@ -1157,12 +1136,8 @@ def get_synced_block_api(sid: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
async def add_synced_to_page(request: Request, page_id: int):
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1357,7 +1332,7 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
@@ -1385,8 +1360,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -1411,7 +1386,7 @@ def get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
@@ -1437,24 +1412,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
@@ -1504,9 +1475,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1605,7 +1576,7 @@ def page_versions(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
@@ -1619,8 +1590,8 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
@@ -1628,7 +1599,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -1664,8 +1635,8 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
@@ -1758,9 +1729,8 @@ def remove_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
body = await request.json()
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
@@ -1935,16 +1905,12 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
@@ -1955,7 +1921,7 @@ async def resolve_embed(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
@@ -1963,10 +1929,6 @@ async def move_page(request: Request, page_id: int):
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
@@ -1997,13 +1959,13 @@ async def move_page(request: Request, page_id: int):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -2019,7 +1981,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -70,10 +71,9 @@ def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Create a page or inline comment. Mentions (@login) notify users."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = (body.get("body") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -124,10 +124,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}))
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}))
|
||||
except Exception:
|
||||
logger.exception("add_comment")
|
||||
|
||||
@@ -135,14 +135,13 @@ async def add_comment(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def notify_page_mentions(request: Request, page_id: int):
|
||||
def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Notify users @-mentioned in a page's content (called on save).
|
||||
|
||||
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
||||
against a per-page cache so repeated auto-saves don't spam notifications.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = body.get("text") or ""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
@@ -157,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}))
|
||||
except Exception:
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
"""Update a comment body or resolve/unresolve it."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
||||
@@ -188,7 +186,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
+52
-140
@@ -6,12 +6,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
@@ -222,12 +222,8 @@ def list_collections_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
async def create_collection_api(request: Request):
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -277,7 +273,7 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -285,12 +281,8 @@ async def create_collection_api(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
async def update_collection_api(request: Request, collection_id: int):
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -316,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
@@ -340,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""})
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
@@ -494,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
@@ -542,12 +534,8 @@ def open_row_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -599,18 +587,18 @@ async def update_page_api(request: Request, page_id: int):
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
@@ -620,7 +608,7 @@ async def update_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
async def delete_page_api(request: Request, page_id: int):
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -634,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.deleted", {
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -851,16 +839,12 @@ def timezones_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
async def set_property_groups_api(request: Request, collection_id: int):
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -887,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
async def create_property_api(request: Request, collection_id: int):
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -934,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
async def update_property_api(request: Request, prop_id: int):
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -991,12 +967,8 @@ def delete_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
async def create_relation_property(request: Request, collection_id: int):
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
@@ -1043,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
async def link_pages(request: Request, collection_id: int):
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
@@ -1117,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
async def compute_rollup(request: Request):
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
@@ -1143,12 +1107,8 @@ async def compute_rollup(request: Request):
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
async def evaluate_formula(request: Request):
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
@@ -1177,12 +1137,8 @@ def get_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1209,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
@@ -1241,12 +1193,12 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
}))
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
@@ -1289,12 +1241,8 @@ def delete_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
async def duplicate_view_api(request: Request, view_id: int):
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1319,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
}))
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
@@ -1344,12 +1292,8 @@ def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
@@ -1380,18 +1324,18 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@@ -1420,12 +1364,8 @@ def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
@@ -1447,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
@@ -1510,12 +1446,8 @@ def list_data_sources(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
async def add_data_source(request: Request, collection_id: int):
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
@@ -1572,15 +1504,11 @@ def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
async def create_linked_database(request: Request, collection_id: int):
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a linked database view from a source collection.
|
||||
A linked database copies the structure (views, filters, sorts) of a source
|
||||
but shares the same pages — edits to pages propagate to the source.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
@@ -1693,12 +1621,8 @@ def toggle_inline(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
async def create_inline_database(request: Request):
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -1792,12 +1716,8 @@ def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
@@ -1834,15 +1754,11 @@ def remove_page_dependency(request: Request, collection_id: int, page_id: int, d
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2646,15 +2562,11 @@ def get_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
@@ -2695,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
+19
-46
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -720,8 +720,7 @@ def _require_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -731,9 +730,8 @@ async def update_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
@@ -972,8 +970,7 @@ async def list_workspace_projects(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
async def create_workspace_project(request: Request):
|
||||
body = await request.json()
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1002,9 +999,8 @@ def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
async def invite_member(request: Request, ws_id: int):
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
body = await request.json()
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
@@ -1025,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
body = await request.json()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
@@ -1468,9 +1463,8 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
async def create_local_workspace_item(request: Request):
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -1491,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -1547,9 +1540,8 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
async def move_local_workspace_item(request: Request, item_id: int):
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
body = await request.json()
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1829,9 +1821,8 @@ def list_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
async def create_workspace(request: Request):
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1861,9 +1852,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
async def rename_workspace(request: Request, ws_id: int):
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1971,9 +1961,8 @@ def get_avatar(user_id: int):
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
async def set_avatar_color(request: Request):
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
body = await request.json()
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1987,9 +1976,8 @@ async def set_avatar_color(request: Request):
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
async def create_tag_global(request: Request):
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -2007,9 +1995,8 @@ async def create_tag_global(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
async def update_tag_global(tag_id: int, request: Request):
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
body = await request.json()
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
@@ -2087,9 +2074,8 @@ def get_item_tags(item_id: int):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def add_item_tag(request: Request, item_id: int):
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -2175,16 +2161,12 @@ def search_by_tags(request: Request, tags: str = ""):
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
async def update_account(request: Request):
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
@@ -2563,9 +2545,8 @@ def api_page_content(page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
async def api_rename_page(page_id: int, request: Request):
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
@@ -2594,17 +2575,13 @@ def api_trash_page(page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
async def api_convert_to_database(page_id: int, request: Request):
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2699,13 +2676,9 @@ def api_collection_table_data(collection_id: int):
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
async def api_create_collection_page(collection_id: int, request: Request):
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
|
||||
+4
-12
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
@@ -202,7 +202,7 @@ def list_private_pages(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -210,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -243,7 +239,7 @@ def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -251,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
@@ -306,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -45,12 +45,8 @@ def list_policies(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
def upsert_policy(request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
@@ -84,12 +80,8 @@ def list_approvals(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
|
||||
@@ -39,7 +39,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
|
||||
+3
-11
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -46,12 +46,8 @@ def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
def create_link(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
@@ -171,13 +167,9 @@ async def upload_and_transcribe(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -56,10 +56,9 @@ def unread_count(request: Request):
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
def mark_read(request: Request, body: dict = Body(default={})):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
@@ -91,11 +90,10 @@ def get_prefs(request: Request):
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
def set_prefs(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
@@ -116,10 +114,9 @@ def get_timezone(request: Request):
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
def set_timezone(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's IANA timezone (empty string = UTC)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
tz = (body.get("timezone") or "").strip()
|
||||
from app.services.recurrence import is_valid_timezone
|
||||
if tz and not is_valid_timezone(tz):
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool:
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
def onboarding_create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Step 1 — create the first local workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip() or "My Workspace"
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
def onboarding_create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Step 3 — create the first project: a welcome page in the workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
|
||||
workspace_id = body.get("workspace_id")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
@@ -266,11 +264,10 @@ def _collection_type(collection_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
@@ -331,11 +328,10 @@ def list_property_permissions(collection_id: int, property_id: int, request: Req
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
@@ -369,9 +365,8 @@ def list_groups(request: Request, workspace_id: int | None = None):
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
def create_group(request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
@@ -382,9 +377,8 @@ async def create_group(request: Request):
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -428,9 +422,8 @@ def list_group_members(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
@@ -29,9 +29,8 @@ def list_projects(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(request: Request):
|
||||
def create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Register a standalone (builtin) project."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
@@ -51,7 +50,7 @@ async def sync_projects(request: Request):
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
@@ -61,7 +60,7 @@ async def run_backup_now(request: Request):
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
|
||||
+6
-26
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -70,12 +70,8 @@ def scim_list(request: Request):
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
def scim_create(request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
@@ -133,12 +129,8 @@ def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
def scim_replace(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -146,12 +138,8 @@ async def scim_replace(user_id: str, request: Request):
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
def scim_patch(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
@@ -180,12 +168,8 @@ def scim_delete(user_id: str, request: Request):
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
def create_scim_token(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
@@ -234,12 +218,8 @@ def list_domains(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
def create_domain(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
|
||||
@@ -8,7 +8,7 @@ import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -45,10 +45,9 @@ def list_tokens(request: Request):
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
def create_token(request: Request, body: dict = Body(default={})):
|
||||
"""Create an API token for the current user. The secret is returned once."""
|
||||
uid = _current_user_id(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "API token"
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
|
||||
+10
-12
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,10 +28,9 @@ def _require_auth(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
@@ -119,7 +119,7 @@ async def share_page(page_id: int, request: Request):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
|
||||
except Exception:
|
||||
logger.exception("share_page")
|
||||
|
||||
@@ -164,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
@@ -293,11 +292,11 @@ def list_shares(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
slug, _title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -307,11 +306,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
@@ -323,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/recents/track")
|
||||
async def track_recent(request: Request):
|
||||
def track_recent(request: Request, body: dict = Body(default={})):
|
||||
"""Record a page access in recents."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
workspace = body.get("workspace", "")
|
||||
source_type = body.get("source_type", "local")
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -81,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
def save_sidebar_config(request: Request, body: dict = Body(...)):
|
||||
"""Save the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
config = body.get("config")
|
||||
if not config or not isinstance(config, dict):
|
||||
|
||||
+5
-21
@@ -19,7 +19,7 @@ import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -256,12 +256,8 @@ def _check_form_rate(ip: str) -> None:
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
def create_site(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
@@ -349,12 +345,8 @@ def get_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -432,12 +424,8 @@ def list_site_pages(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -622,12 +610,8 @@ def get_form_config(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
|
||||
+14
-18
@@ -17,7 +17,7 @@ import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
@@ -496,7 +496,7 @@ async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=l
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
@@ -558,22 +558,18 @@ def sso_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
def save_sso_config_api(request: Request, payload: dict = Body(...)):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
@@ -586,9 +582,9 @@ async def save_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
@@ -597,9 +593,9 @@ async def delete_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -616,9 +612,9 @@ async def sso_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
@@ -630,9 +626,9 @@ async def sso_sync(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict:
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
@@ -101,12 +101,8 @@ async def clipper_status(request: Request):
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
def auth_verify(request: Request, body: dict = Body(default={})):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
@@ -124,7 +120,7 @@ async def auth_verify(request: Request):
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
def clip_page(request: Request, body: dict = Body(...)):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
@@ -134,10 +130,6 @@ async def clip_page(request: Request):
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
@@ -209,14 +201,14 @@ async def clip_page(request: Request):
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
|
||||
+4
-16
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -76,15 +76,11 @@ def register_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
def register_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
@@ -115,14 +111,10 @@ async def register_finish(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
def login_begin(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
@@ -144,14 +136,10 @@ async def login_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
def login_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
|
||||
+7
-31
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -172,12 +172,8 @@ def teamspace_page(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/teamspaces")
|
||||
async def create_teamspace(request: Request):
|
||||
def create_teamspace(request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
@@ -225,15 +221,11 @@ def list_members(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
@@ -275,15 +267,11 @@ def get_verification(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
def verify_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
@@ -355,12 +343,8 @@ def list_followers(page_id: int, request: Request):
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
def react(comment_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
@@ -380,15 +364,11 @@ def list_reactions(comment_id: int, request: Request):
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
def create_guest(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
@@ -519,13 +499,9 @@ def sweep_expiry(request: Request):
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
def preview_blocks(request: Request, body: dict = Body(default={})):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
|
||||
+3
-11
@@ -1,7 +1,7 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -41,12 +41,8 @@ def _row_to_api(row) -> dict:
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
def create_worker(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
@@ -101,12 +97,8 @@ def get_worker(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
def update_worker(worker_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
|
||||
+28
-47
@@ -8,12 +8,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -57,8 +57,7 @@ def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
@@ -91,9 +90,8 @@ def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -108,9 +106,8 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
@@ -143,8 +140,7 @@ def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -158,15 +154,14 @@ async def add_comment(request: Request, page_id: int):
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
@@ -178,7 +173,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
@@ -197,8 +192,7 @@ def page_history(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -230,8 +224,7 @@ def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
@@ -244,7 +237,7 @@ async def add_favorite(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await 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, "collection_id": collection_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
@@ -268,8 +261,7 @@ def list_db_templates(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_db_template(request: Request, body: dict = Body(default={})):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
@@ -282,9 +274,8 @@ async def create_db_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
|
||||
from app.services.db_templates import create_from_template
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
@@ -305,8 +296,7 @@ def list_page_templates(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
@@ -317,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||
if not tmpl:
|
||||
@@ -331,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
}))
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
"""Update a page template — name, properties, content, recurrence."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
@@ -395,9 +383,8 @@ def list_dashboards(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard(request: Request, collection_id: int):
|
||||
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new dashboard for a collection."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Dashboard").strip()
|
||||
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||
|
||||
@@ -414,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
|
||||
"""Update a dashboard — name or layout (widgets grid)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
@@ -471,9 +457,8 @@ def list_sprints(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
@@ -491,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint")
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
@@ -521,7 +505,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("update_sprint")
|
||||
return {"id": sid, "status": "updated"}
|
||||
@@ -542,9 +526,8 @@ def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -634,8 +617,7 @@ def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
@@ -698,9 +680,8 @@ def list_webhooks(request: Request):
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
def create_webhook(request: Request, body: dict = Body(default={})):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,535 +9,5 @@
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
|
||||
+2
-147
@@ -151,151 +151,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1094
File diff suppressed because it is too large
Load Diff
+2123
-1
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
};
|
||||
|
||||
export default [
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch { /* volontaire */ } }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
@@ -95,7 +95,7 @@
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch { /* volontaire */ } this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
if (fn) { try { fn(''); } catch { /* volontaire */ } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
catch { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
@@ -151,7 +151,7 @@
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
@@ -160,7 +160,7 @@
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
} catch { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
@@ -196,7 +196,7 @@
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
@@ -206,7 +206,7 @@
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+20
-24
@@ -430,7 +430,7 @@
|
||||
if(!lastAsst) return;
|
||||
var steps = acts.map(function(a){
|
||||
var res = null;
|
||||
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch(e){ res = {}; }
|
||||
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch{ res = {}; }
|
||||
var msg = (res && res.message) ? res.message : '';
|
||||
return agentPanel._actionStep({
|
||||
tool: a.tool_name, status: a.status, target_id: a.target_id,
|
||||
@@ -672,7 +672,7 @@
|
||||
r.insertNode(br);
|
||||
r.setStartAfter(br); r.collapse(true);
|
||||
this.input = this.composerText();
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
this.resizeInput();
|
||||
},
|
||||
_caretQuery(){
|
||||
@@ -687,7 +687,7 @@
|
||||
if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){
|
||||
return {node: node, at: i-1, raw: s.slice(i, off)};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
},
|
||||
_expandToAt(range){
|
||||
@@ -727,14 +727,14 @@
|
||||
useRange.setStartAfter(span); useRange.collapse(true);
|
||||
useRange.insertNode(sp);
|
||||
useRange.setStart(sp, 1); useRange.setEnd(sp, 1);
|
||||
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){}
|
||||
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch { /* volontaire */ }
|
||||
} else {
|
||||
if(el.textContent) el.appendChild(document.createTextNode(' '));
|
||||
el.appendChild(span);
|
||||
el.appendChild(document.createTextNode(' '));
|
||||
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){}
|
||||
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch { /* volontaire */ }
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
this.input = this.composerText();
|
||||
this.resizeInput();
|
||||
this._emptyCheck();
|
||||
@@ -753,7 +753,7 @@
|
||||
this._insertToken(item, r);
|
||||
return;
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
}
|
||||
this._insertToken(item);
|
||||
},
|
||||
@@ -803,12 +803,12 @@
|
||||
onPaste(e){
|
||||
e.preventDefault();
|
||||
var text = '';
|
||||
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch(err){ }
|
||||
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch { /* volontaire */ }
|
||||
if(!text) return;
|
||||
try{
|
||||
var sel = window.getSelection();
|
||||
if(sel && sel.rangeCount){ sel.getRangeAt(0).deleteContents(); document.execCommand('insertText', false, text); }
|
||||
}catch(err){}
|
||||
}catch { /* volontaire */ }
|
||||
this.input = this.composerText();
|
||||
this.resizeInput();
|
||||
},
|
||||
@@ -889,7 +889,6 @@
|
||||
return null;
|
||||
},
|
||||
useSuggestion(c){
|
||||
var self = this;
|
||||
if(!c || c._sep) return;
|
||||
if(c._admin){
|
||||
this.setComposerText(c.cmd.replace(/\s*<[^>]*>\s*$/, ' '));
|
||||
@@ -926,7 +925,6 @@
|
||||
this.scrollBottom();
|
||||
},
|
||||
handleCommand(text){
|
||||
var self = this;
|
||||
var parts = text.replace(/^\/+/, '').trim().split(/\s+/);
|
||||
var cmd = (parts.shift() || '').toLowerCase();
|
||||
var arg = parts.join(' ').trim();
|
||||
@@ -1164,7 +1162,7 @@
|
||||
if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; }
|
||||
}
|
||||
}
|
||||
}catch(err){}
|
||||
}catch { /* volontaire */ }
|
||||
}
|
||||
if(key === 'Escape'){
|
||||
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
|
||||
@@ -1219,7 +1217,6 @@
|
||||
// Render agent markdown responses (headings, tables, code, lists, …) to
|
||||
// HTML so the panel displays content like Notion (no raw `##` / `**`).
|
||||
renderMarkdown(md){
|
||||
var self = this;
|
||||
function escHtml(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function inline(t){
|
||||
t = escHtml(t);
|
||||
@@ -1261,7 +1258,6 @@
|
||||
var t = raw.trim();
|
||||
// Fenced code block
|
||||
if(t.startsWith('```')){
|
||||
var lang = t.substring(3).trim();
|
||||
var code = [];
|
||||
i++;
|
||||
while(i < lines.length && !lines[i].trim().startsWith('```')){
|
||||
@@ -1351,7 +1347,7 @@
|
||||
var ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok);
|
||||
}catch(err){ self.toast('Copie impossible.', true); }
|
||||
}catch{ self.toast('Copie impossible.', true); }
|
||||
}
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); });
|
||||
@@ -1418,7 +1414,7 @@
|
||||
if(ment.length){ text = 'Analyse le contexte référencé.'; }
|
||||
else if(!hasSkills){ return; }
|
||||
}
|
||||
try { await self.ensureConversation(); } catch(e){ return; }
|
||||
try { await self.ensureConversation(); } catch{ return; }
|
||||
var rawText = text;
|
||||
// La requête est composée EN ARRIÈRE-PLAN (hors de la boîte d'édition) :
|
||||
// contexte1 + contexte2 + skill-1 + skill-2 + texte.
|
||||
@@ -1505,7 +1501,7 @@
|
||||
fph.steps = runSteps.slice();
|
||||
fph.stepsOpen = false;
|
||||
fph.model = self.autoLabel || '';
|
||||
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
|
||||
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch { /* volontaire */ } }
|
||||
}
|
||||
else if(ev.type==='notice'){ self.notice = ev.message; }
|
||||
else if(ev.type==='error'){
|
||||
@@ -1515,7 +1511,7 @@
|
||||
eph.steps = runSteps.slice();
|
||||
self.errorMsg = ev.message;
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
});
|
||||
self.scrollBottom();
|
||||
return read();
|
||||
@@ -1631,7 +1627,7 @@
|
||||
var out = title ? ('Titre du document : ' + title) : 'Document ouvert';
|
||||
if(text) out += '\n\n' + text;
|
||||
return out.slice(0, 9000);
|
||||
}catch(e){ return ''; }
|
||||
}catch{ return ''; }
|
||||
};
|
||||
if(!window.fdGetPageContext) window.fdGetPageContext = window.fdReadPageContext;
|
||||
|
||||
@@ -1644,7 +1640,7 @@
|
||||
try{
|
||||
var m = (window.location.pathname || '').match(new RegExp('^\\/?' + prefix + '\\/(\\d+)'));
|
||||
return m ? m[1] : null;
|
||||
}catch(e){ return null; }
|
||||
}catch{ return null; }
|
||||
}
|
||||
function labelFromTitle(){
|
||||
try{
|
||||
@@ -1652,7 +1648,7 @@
|
||||
t = t.replace(/^\s*FlowDeck\s*-\s*/i, '');
|
||||
t = t.replace(/\s*[-|]\s*FlowDeck\s*$/i, '');
|
||||
if(t && t.toLowerCase() !== 'home' && t.toLowerCase() !== 'flowdeck') return t;
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return '';
|
||||
}
|
||||
function docContext(){
|
||||
@@ -1671,7 +1667,7 @@
|
||||
refType: pid ? 'document' : null
|
||||
};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
}
|
||||
function marker(){
|
||||
@@ -1686,7 +1682,7 @@
|
||||
var lbl = b.getAttribute('data-fd-context');
|
||||
return {kind:'section', icon:'📍', label:lbl, text:'', key:'marker'};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
}
|
||||
function byPath(){
|
||||
@@ -1777,7 +1773,7 @@
|
||||
if(E.autoSave) E.autoSave();
|
||||
if(E.render) E.render();
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}catch{ return false; }
|
||||
};
|
||||
|
||||
// Backward-compatible detection hooks (banner + Ask AI)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
var owner = BD.owner;
|
||||
var repo = BD.repo;
|
||||
var initialView = BD.initial_view;
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var d = await r.json();
|
||||
this.treeItems = d.tree || [];
|
||||
this._sortTree();
|
||||
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
} catch { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
finally { this.treeLoading = false; }
|
||||
},
|
||||
|
||||
@@ -267,7 +267,7 @@ document.addEventListener('alpine:init', () => {
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async openFile(path, sha) {
|
||||
@@ -286,7 +286,7 @@ document.addEventListener('alpine:init', () => {
|
||||
} else {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
this.fileLoading = false;
|
||||
@@ -343,7 +343,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Network error', 'error'); }
|
||||
} catch { window.showToast('Network error', 'error'); }
|
||||
},
|
||||
|
||||
async deleteCurrentFile() {
|
||||
@@ -358,7 +358,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.filePath = '';
|
||||
await this.refreshTree();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async refreshTree() {
|
||||
@@ -380,7 +380,7 @@ document.addEventListener('alpine:init', () => {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
newPrivate() {
|
||||
@@ -398,7 +398,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.editingPrivateTitle = d.page.title;
|
||||
this.editingPrivateContent = d.page.content;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
@@ -419,7 +419,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.editingPrivateContent = '';
|
||||
await this.loadPrivatePages();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
// Create new file
|
||||
@@ -477,7 +477,7 @@ document.addEventListener('alpine:init', () => {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
getCsrfToken() {
|
||||
|
||||
+7
-6
@@ -1,3 +1,4 @@
|
||||
/* exported importWizard -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function importWizard() {
|
||||
return {
|
||||
@@ -25,11 +26,11 @@ function importWizard() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
@@ -61,7 +62,7 @@ function importWizard() {
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
} catch { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
@@ -82,7 +83,7 @@ function importWizard() {
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
} catch { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
@@ -115,7 +116,7 @@ function importWizard() {
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
} catch { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
@@ -132,7 +133,7 @@ function importWizard() {
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
} catch { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
|
||||
+15
-15
@@ -1,3 +1,4 @@
|
||||
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
@@ -298,7 +299,7 @@ function libraryPage() {
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
@@ -389,7 +390,7 @@ function libraryPage() {
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf},
|
||||
body: JSON.stringify({ parent_id: parentId || 0 })
|
||||
});
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
this.clearSelection();
|
||||
this.loadTab();
|
||||
@@ -537,7 +538,6 @@ function libraryPage() {
|
||||
doSort() {
|
||||
var s = this.sortBy;
|
||||
if (!s) return;
|
||||
var self = this;
|
||||
function sortNodes(nodes) {
|
||||
if (!nodes) return;
|
||||
nodes.sort(function(a,b){
|
||||
@@ -574,7 +574,7 @@ function libraryPage() {
|
||||
item.children = d.items || [];
|
||||
// Mark the tree for depth calculation
|
||||
this._markChildren(item.children, (item.depth || 0) + 1);
|
||||
} catch(e) {
|
||||
} catch {
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
@@ -701,7 +701,7 @@ function libraryPage() {
|
||||
item.page_icon = icon;
|
||||
this._renderTable();
|
||||
if (window.showToast) window.showToast('Icon updated', 'success');
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to update icon', 'error');
|
||||
}
|
||||
},
|
||||
@@ -712,7 +712,7 @@ function libraryPage() {
|
||||
var r = await fetch('/api/settings/tags/all');
|
||||
var d = await r.json();
|
||||
this.workspaceTags = d.tags || [];
|
||||
} catch(e) { this.workspaceTags = []; }
|
||||
} catch { this.workspaceTags = []; }
|
||||
},
|
||||
|
||||
toggleTagFilter(tagName) {
|
||||
@@ -771,7 +771,7 @@ function libraryPage() {
|
||||
this._ctxRefreshAvail();
|
||||
this._recomputeFlatItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async toggleFavoriteItem(item) {
|
||||
@@ -809,7 +809,7 @@ function libraryPage() {
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Page duplicated', 'success');
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
|
||||
}
|
||||
},
|
||||
@@ -828,7 +828,7 @@ function libraryPage() {
|
||||
var all = (d.items || []).filter(function(it) { return ids.indexOf(it.id) === -1; });
|
||||
this._moveAllCandidates = all;
|
||||
this.moveCandidates = all.slice(0, 30);
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this._moveAllCandidates = [];
|
||||
this.moveCandidates = [];
|
||||
}
|
||||
@@ -881,7 +881,7 @@ function libraryPage() {
|
||||
if (r.ok) {
|
||||
item.title = newName;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
this._renderTable();
|
||||
this._syncSidebar();
|
||||
},
|
||||
@@ -908,7 +908,7 @@ function libraryPage() {
|
||||
var cr = await fetch('/api/library/children/' + parentId);
|
||||
var cd = await cr.json();
|
||||
parent.children = cd.items || [];
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
this._recomputeFlatItems();
|
||||
this._renderTable();
|
||||
return;
|
||||
@@ -917,7 +917,7 @@ function libraryPage() {
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
}
|
||||
},
|
||||
@@ -996,7 +996,7 @@ function libraryPage() {
|
||||
if (days < 7) return days + 'd ago';
|
||||
if (days < 30) return Math.floor(days/7) + 'w ago';
|
||||
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
||||
} catch(e) { return d; }
|
||||
} catch { return d; }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1029,11 +1029,11 @@ function libraryPage() {
|
||||
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {}
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch(ex) {}
|
||||
} catch { /* volontaire */ }
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: RT.id,
|
||||
login: RT.login,
|
||||
full_name: RT.full_name,
|
||||
color: RT.color,
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function workspacesPage() {
|
||||
return {
|
||||
@@ -105,7 +106,7 @@ function workspacesPage() {
|
||||
myLogin = meData.user?.login || 'Me';
|
||||
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
|
||||
// User repos (no org filter)
|
||||
try {
|
||||
@@ -117,7 +118,7 @@ function workspacesPage() {
|
||||
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
|
||||
// Org repos
|
||||
for (const org of orgs) {
|
||||
@@ -129,13 +130,13 @@ function workspacesPage() {
|
||||
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this.giteaStatus = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -294,7 +294,7 @@ def test_editor_has_autocomplete(client):
|
||||
|
||||
|
||||
def test_database_has_ai_properties(client):
|
||||
src = _read_template("_database_table_scripts.html")
|
||||
src = _read_js("database_table.js")
|
||||
assert "db-ai-fill-btn" in src
|
||||
assert "aiFillRow" in src
|
||||
assert "/api/agent/writing/properties" in src
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import tempfile
|
||||
|
||||
@@ -273,8 +274,10 @@ def test_collection_page_renders_multi_view(client):
|
||||
resp = client.get(f"/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert resp.status_code == 200
|
||||
assert "db-view-bar" in resp.text
|
||||
assert "FlowDeckDB" in resp.text
|
||||
# New view types + settings are available client-side.
|
||||
assert "db-board" in resp.text
|
||||
assert "db-cal-grid" in resp.text
|
||||
assert "db-gallery" in resp.text
|
||||
# (A27 : le JS de la DB est extrait dans static/js/database_table.js.)
|
||||
src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||||
assert "FlowDeckDB" in src
|
||||
assert "db-board" in src
|
||||
assert "db-cal-grid" in src
|
||||
assert "db-gallery" in src
|
||||
|
||||
@@ -417,7 +417,7 @@ def test_notification_prefs_extended_keys(client):
|
||||
|
||||
def test_calendar_front_end_wired(client):
|
||||
import pathlib
|
||||
src = pathlib.Path("app/templates/_database_table_scripts.html").read_text(encoding="utf-8")
|
||||
src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||||
assert "calendar/api" in src
|
||||
assert "data-cal-mode" in src and "refreshCalendar" in src
|
||||
assert "openEventEditor" in src and "__recurrence__" in src and "__reminder__" in src
|
||||
|
||||
Reference in New Issue
Block a user