Compare commits

..
1 Commits
Author SHA1 Message Date
bruno 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
498 changed files with 3245 additions and 406314 deletions
-29
View File
@@ -9,13 +9,6 @@ GITEA_WEBHOOK_SECRET=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
# ── OAuth2 ──
# Laisser VIDE = redirect URI dynamique (dérivée du Host/X-Forwarded-* de la requête).
# Ne définir QUE si on veut forcer une URI exacte — elle DOIT être enregistrée
# dans l'application OAuth2 côté Gitea/GitHub (Settings → Applications).
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI=
# ── App ──
APP_SECRET_KEY=change-me-to-random
APP_HOST=0.0.0.0
@@ -31,25 +24,3 @@ DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ──
SYNC_INTERVAL=60
GITEA_CACHE_TTL=30
# ── Backups (v5.2.0) ──
# Sauvegarde automatique quotidienne du fichier SQLite (fichiers datés).
BACKUP_ENABLED=true
BACKUP_DIR=/data/backups
BACKUP_INTERVAL_HOURS=24
BACKUP_KEEP=30
# ── Forge projects sync (v5.2.0) ──
# Rafraîchissement périodique de la table `projects` depuis les forges connectées.
PROJECT_SYNC_ENABLED=true
PROJECT_SYNC_INTERVAL_HOURS=1
# ── Email notifications (v4.9.0) ──
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=FlowDeck <[email protected]>
SMTP_USE_TLS=true
APP_BASE_URL=http://localhost:8080
+9 -38
View File
@@ -1,57 +1,28 @@
name: FlowDeck CI
on:
# Run on every pushed branch so feature branches are validated before the PR.
push:
branches: [main]
pull_request:
branches: [main, develop]
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
run: ruff check app tests
- name: ESLint (JavaScript)
run: npx --yes eslint static/js
test:
runs-on: ubuntu-latest
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
# JavaScript `actions/checkout` action could not run and every job failed at
# the first step. The runner's default image already provides Node; we install
# the Python toolchain explicitly with actions/setup-python.
container: python:3.12-slim
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install system dependencies (WeasyPrint / emoji fonts)
run: |-
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
- name: Install Python dependencies
run: pip install -r requirements-dev.txt pytest-cov
- name: Run tests (parallel) with coverage
- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-cov
- name: Run tests with coverage
env:
GITEA_URL: https://git.dracodev.net
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
APP_SECRET_KEY: ci-test-key
# `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
- name: Coverage summary
if: always()
run: coverage report -m || true
run: |
python -m pytest tests/ --cov=app --cov-report=term 2>&1 | tail -20
docker:
runs-on: ubuntu-latest
-2
View File
@@ -4,7 +4,6 @@ __pycache__/
/data/
.venv/
venv/
.venv*/
*.egg-info/
dist/
.pytest_cache/
@@ -16,4 +15,3 @@ dist/
.ua/tmp/
.ua/.trash-*/
.ua/.understandignore
uv.lock
+64 -215
View File
@@ -1,8 +1,7 @@
# Architecture FlowDeck — Document Complet v4.0
# Architecture FlowDeck — Document Complet v3.0
> **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net)
---
@@ -48,31 +47,34 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Templates Jinja2 (SSR) │ │
│ │ ├─ base.html — Layout Notion commun (sidebar) │ │
│ │ ├─ _header.html — Topbar + breadcrumbs │ │
│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │
│ │ ├─ landing.html — Landing page non-auth │ │
│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │
│ │ ├─ library.html — Bibliothèque multi-onglets │ │
│ │ ├─ local_workspace.html — Workspace local (explorer) │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ trash.html — Corbeille (local only) │ │
│ │ ├─ settings.html — Settings / My Account / Admin │ │
│ │ ├─ accounts.html — Gestion comptes admin │ │
│ │ ├─ board.html — Kanban (wrapper) │ │
│ │ ├─ board_fragment.html — Cartes Kanban │ │
│ │ ├─ table_view.html — Vue table │ │
│ │ ├─ login.html — Login/Register (local + OAuth) │ │
│ │ ├─ public_page.html — Page publiée (/p/slug) │ │
│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
│ │ ├─ dashboard.html — Projets + sidebar │ │
│ │ ├─ board.html — Kanban (wrapper) │ │
│ │ ├─ board_fragment.html— Cartes Kanban │ │
│ │ ├─ table_view.html — Vue table │ │
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │
│ │ ├─ list_view.html — Vue liste (v1.6) │ │
│ │ ├─ status_overview.html — Donut SVG │ │
│ │ ├─ team_load.html — Barres stacked │ │
│ │ ├─ detailed_board.html— Board détaillé │ │
│ │ ├─ card_detail.html — Modale détail carte │ │
│ │ ├─ card.html — Carte individuelle │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ my_tasks.html — Dashboard unifié (v1.9) │ │
│ │ ├─ library.html — Bibliothèque de pages │ │
│ │ ├─ trash.html — Corbeille │ │
│ │ ├─ accounts.html — Gestion comptes │ │
│ │ ├─ notes.html — Notes Markdown │ │
│ │ └─ base.html — Layout commun │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ JavaScript (servi localement, pas de CDN externe) │ │
│ │ JavaScript (CDN, pas de build step) │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
│ │ └─ Prism.js — Syntax highlighting │ │
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
@@ -93,27 +95,17 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ROUTERS │ │
│ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │
│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │
│ │ │ — /trash, /settings, /accounts │ │
│ │ │ — /p/{slug} (pages publiques) │ │
│ │ │ — _sidebar_data() — données sidebar │ │
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
│ │ │ — /board/api/pages CRUD pages │ │
│ │ │ — /board/api/trash Trash │ │
│ │ ├─ api.py — /api/... CRUD + sync │ │
│ │ ├─ auth.py — /auth/... OAuth2 + local │ │
│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
│ │ ├─ library.py — /api/library/* API bibliothèque│ │
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
│ │ ├─ collections.py — /db/... Collections │ │
│ │ ├─ editor.py — /api/editor/... Block editor │ │
│ │ ├─ private.py — /api/private/* Section privée │ │
│ │ ├─ public_api.py — /api/public/* Public API │ │
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
│ │ └─ admin.py — /api/admin/* Admin users │ │
│ │ ├─ dashboard.py — / Dashboard │ │
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
│ │ │ — /board/api/... APIs board │ │
│ │ ├─ api.py — /api/... CRUD + sync │ │
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │
│ │ ├─ auth.py — /auth/... OAuth2 │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
│ │ ├─ collections.py — /db/... Collections │ │
│ │ └─ editor.py — /api/editor/... Block editor │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
@@ -146,11 +138,6 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │ │ ├─ property_values (legacy → collection_pages) │
│ │ │ ├─ ai_keywords │ │ │
│ │ │ ├─ pages │ │ │
│ │ │ ├─ page_shares (v4.0) │ │ │
│ │ │ ├─ recents (v4.0) │ │ │
│ │ │ ├─ tags (v4.0) │ │ │
│ │ │ ├─ page_tags (v4.0) │ │ │
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
│ │ │ ├─ users │ │ │
│ │ │ ├─ user_tokens │ │ │
│ │ │ ├─ workspaces (v2.0) │ │ │
@@ -698,146 +685,42 @@ POST /favorites → Ajouter favori
DELETE /favorites/{id} → Retirer favori
```
### 4.6 Routes Sidebar & Library (v4.0)
```
GET /library → Bibliothèque dynamique
GET /api/library/recents → Pages récentes
GET /api/library/favorites → Favoris
GET /api/library/shared → Pages partagées
GET /api/library/published → Pages publiées
GET /api/library/private → Pages privées
GET /api/library/workspace → Pages du workspace
GET /api/local-workspace/items → Arborescence workspace
POST /api/local-workspace/items → Créer page/dossier
PUT /api/local-workspace/items/{id} → Renommer
DELETE /api/local-workspace/items/{id} → Soft delete
POST /api/local-workspace/upload → Upload fichier
GET /api/files/{ws_id}/{filename} → Servir fichier uploadé
GET /help → Page d'aide complète
GET /api/public/pages/{slug} → Page publique
```
### 4.7 Routes Share & Publish (v4.0)
```
POST /api/pages/{id}/share → Partager une page
DELETE /api/pages/{id}/share/{share_id} → Retirer partage
GET /api/pages/{id}/shares → Liste partages
POST /api/pages/{id}/publish → Publier page
DELETE /api/pages/{id}/publish → Dépublier
```
---
## 5. Système d'authentification (v4.0)
FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local.
### 5.1 Types de comptes
| Type | Auth | Workspaces | Intégrations |
|------|------|------------|--------------|
| **Local pur** | email + password | Locaux uniquement | Aucune |
| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub |
| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable |
### 5.2 Comportement du sidebar selon le type de compte
```
LOCAL PUR:
├─ 📁 [nom workspace local]
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared ← visible si pages partagées
├─ 🌐 Published ← visible si pages publiées
├─ (Private caché)
└─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help
LOCAL + GITEA/GITHUB:
├─ 🔗 [owner/repo] ← workspace distant actif
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared
├─ 🌐 Published
├─ 🔒 Private ← visible (fichiers locaux liés au projet distant)
└─ 📚 / ✅ / 🗑️ / ❓
OAUTH PUR (GITEA/GITHUB):
├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 👥 Shared
├─ 🌐 Published
├─ (Private caché)
└─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local)
```
### 5.3 Règles d'affichage
- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif
- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent
- **Boutons New File/New Folder** : cachés si `has_active_workspace == False`
- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif
- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations
### 5.4 cookie `flowdeck_workspace`
- `""` → aucun workspace actif
- `"42"` → workspace local ID 42
- `"gitea:owner:repo"` → workspace distant Gitea
- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user)
---
## 6. Frontend
## 5. Frontend
### 5.1 Layout
```ascii
```
┌──────────────────────────────────────────────────────────────┐
│ TOPBAR (unified-header) │
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
│ └──────┴───────────────────────────────────────┴───────────┘ │
│ TOPBAR (44px, sticky) │
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
│ └──────────┴──────────────────────────────────┴────────────┘ │
├────────────┬─────────────────────────────────────────────────┤
│ SIDEBAR │ CONTENU PRINCIPAL │
│ (240px) │ │
│ │ ┌─ Help Page ────────────────────────────────┐│
│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
│ │ [D] │ │ │ │ Started │ │ & Editor │ ││
│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
│ └────────┘ │ └─────────────────────────────────────────────┘│
│ │ │
│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
│ │ │ [Recents|Favorites|Shared|Published|...] ││
│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
│ │ │ └──────┴──────────┴──────────┴───────────┘ ││
│ 📅 Meeting │ └─────────────────────────────────────────────┘│
│ 🕒 Recents │ │
│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│
│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││
│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││
│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││
│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││
│ ══════════ │ └─────────────────────────────────────────────┘│
│ 📚 Library │ │
│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│
│ 🗑️ Trash │ │ My Account | Admin | Integrations ││
│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││
│ │ ┌─────────────────────────────────────────────┐│
│ Workspace │ │ DATABASE VIEW ││
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││
│ ▼ Private │ └─────────────────────────────────────────────┘│
│ · Notes │ │
│ │ ┌─ Barre d'outils ────────────────────────────┐│
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││
│ │ └─────────────────────────────────────────────┘│
│ 💬 [+📄] │ │
│ ────────── │ │
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
│ footer) │ └─────────────────────────────────────────────┘│
└────────────┴─────────────────────────────────────────────────┘
```
@@ -1670,41 +1553,7 @@ docker compose restart flowdeck
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
- [Notion Data Sources (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
- [react-notion-x](https://github.com/NotionX/react-notion-x)
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
- [FlowDeck — ROADMAP.md](ROADMAP.md)
---
## 16. Versions à venir
### v4.1.0 — Content Blocks enrichis (priorité standard)
- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger)
- **Table of Contents** — Auto-généré depuis les headings
- **LaTeX / KaTeX** — Formules mathématiques
- **Toggle lists** — Listes pliables/dépliables
- **Multi-columns** — Mise en page 2-3 colonnes
### v4.2.0 — Export (priorité standard)
- Export **Markdown** avec images
- Export **PDF** (via WeasyPrint ou headless Chrome)
- Export **HTML** standalone (page auto-suffisante)
### v4.3.0 — Collaboration (priorité basse)
- **Commentaires inline** sur les pages
- **@mentions** pour notifier des utilisateurs
- **Notifications email** (changements, mentions)
- **Permissions par workspace** (viewer, editor, admin)
### v5.0.0 — Plateforme avancée (futur)
- **AI Assistants** — Génération de contenu, résumés, suggestions
- **Embeds** — Vidéos, PDF, Figma, Google Docs
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
- **Base de données avancée** — Relations inter-collections, rollups
- **Kanban flexible** — Colonnes custom, WIP limits
- **API publique REST** — Tokens d'accès pour intégrations tierces
- **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling
+1 -1462
View File
File diff suppressed because it is too large Load Diff
+6 -31
View File
@@ -1,44 +1,19 @@
# ═══════════════════════════════════════════════════════════
# FlowDeck — multi-stage Docker build (v5.2.0)
# Stage 1 "builder": build Python wheels once.
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ── runtime stage ───────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
# colour emoji support. curl = healthcheck.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi-dev \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-dejavu-core \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data /data/backups
RUN mkdir -p /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+64 -549
View File
@@ -195,561 +195,78 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
- [ ] **CSRF token refresh** — après expiration session (à faire)
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
- [x] **143 tests passent**
---
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
- [x] **153 tests passent** (+10 nouveaux)
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [x] **159 tests passent** (+6 nouveaux)
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **166 tests passent** (+7 nouveaux)
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
- [x] **171 tests passent** (+5 nouveaux)
### v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
- [x] **Tables `sprints`, `sprint_pages`** — sprints agiles avec velocity_points
- [x] **API sprints** — CRUD + assignation/retrait pages
- [x] **Burndown chart** — GET /workspace/collections/{id}/sprints/burndown/{sid} (total/completed/remaining/ideal)
- [x] **My Tasks** — agrégation cross-databases avec filtre (all/today/overdue/upcoming)
- [x] **175 tests passent** (+4 nouveaux)
### v4.6.0 — Content Blocks enrichis ✅ (2026-09-02)
### v4.1.0 — Content Blocks enrichis (Notion Parity Tier 2)
> **Objectif** : parité d'édition avec Notion.
- [x] **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône
- [x] **Table of contents** — auto-généré depuis les headings avec ancres
- [x] **Math equations** — LaTeX / KaTeX (self-hosted) block
- [x] **Toggle lists** — contenu expandable/collapsible avec enfants
- [x] **Multi-colonnes** — layout flexible (2, 3 colonnes) + bouton ajouter/retirer colonne
Détails livrés :
- 5 types de blocs enrichis dans le slash menu (callout, table_of_contents, math, columns, toggle)
- Rendu des nouveaux blocs dans les pages publiques (`/p/<slug>`) — TOC ancré, KaTeX, colonnes, toggle `<details>`
- Intégration KaTeX 0.16.11 self-hosté (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`)
- Persistance `children` (colonnes/toggle) via le format de blocs JSON
- Export Markdown étendu aux nouveaux blocs
- 183 tests au total (dont 5 nouveaux tests v4.6.0)
### v4.7.0 — Export ✅ (2026-09-03)
> **Objectif** : exporter une page (ou un site) depuis l'éditeur.
- [x] **Export Markdown** — avec images et sous-pages (récursif)
- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système)
- [x] **Export HTML** — site statique standalone (document autonome + site .zip)
Détails livrés :
- Service serveur `app/services/export.py` : conversion blocs → Markdown / HTML / PDF
- 4 formats exposés : `/api/export/markdown|html|pdf|site/{page_id}`
- Sous-pages incluses récursivement (Markdown et site)
- UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
- PDF généré via `xhtml2pdf` (pip) — aucun lib système requise
- 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404)
### v4.7.1 — Fix UI Export / Share / More ✅ (2026-09-03)
- [x] **Share / More / Activity / Move-to popovers** — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
- [x] **Sous-menu Export** — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
- [x] Vérifié Playwright headless + 190/190 tests
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
- [x] **Émojis PDF** — passage du moteur à **WeasyPrint** (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
### v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)
- [x] **Bloc `table` éditable dans l'éditeur** — insertion via `/table` (commandes slash, section BASIC), tableau 3×3 avec en-tête, cellules éditables au clic, auto-sauvegarde.
- [x] **Lignes** — bouton `+ Row` sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
- [x] **Colonnes** — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
- [x] **Import Markdown (GFM)** — `md2b` transforme les tableaux pipe en bloc `table` au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc `table`.
### v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)
- [x] **« + Row » (bas) corrigé** — l'insertion de ligne utilisait `splice(index, ligne)` (mauvaise signature) → aucune ligne ajoutée ; désormais `splice(index, 0, ligne)`.
- [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »).
- [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`.
### v4.9.0 — Collaboration ✅ (2026-09-04)
> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**.
> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md)
- [x] **Inline comments** — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table `comments` étendue (`target_type`/`target_id`/`anchor_block_id`/`anchor_start`/`anchor_end`) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar
- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu)
- [x] **Email notifications** — service SMTP (`app/services/mailer.py` + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config `.env` (`SMTP_*`, `APP_BASE_URL`) ; repli no-op sans SMTP
- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
- [x] **208 tests passent** (+9 v4.9.0)
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
**Documentation :**
[`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
[`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
#### Concept
Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agent **réalise** :
`objectif → planification → collecte contexte → actions API → résultat`
| Notion AI | FlowDeck Agent |
|-----------|----------------|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
| Bloc de texte | Crée collections, pages, propriétés, vues |
| Sans état | Historique conversation + snapshots rollback |
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
#### Fonctionnalités clés
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
#### Architecture (nouveaux composants)
```
app/
├── routers/agent.py — /api/agent/* (run, conversations, tools)
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
├── services/llm_client.py — Abstraction multi-fournisseur LLM
├── services/tool_registry.py — 10+ outils wrappant les API existantes
├── services/context_builder.py — Collecte contexte workspace
└── templates/
├── agent_panel.html — Panneau conversation Alpine.js
└── agent_message.html — Fragment message (streaming)
```
#### Nouvelles tables
| Table | Rôle |
|-------|------|
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
#### Cas d'usage FlowDeck
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
- **Générer des OKR** — crée la database OKR avec relations et vues
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
#### Plan de migration (5 phases)
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
#### Limitations (v1)
- Pas de modification concurrente (lock optimiste DB)
- Pas d'appels API externes non-FlowDeck (sandbox strict)
- Budget tokens max par conversation (500k tokens)
- Timeout 5 minutes par run
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
- [x] **Quick actions** — navigation, création, commandes
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
### v5.1.0 — Automations ✅ (2026-09-07)
> **Objectif** : moteur de règles if-this-then-that + boutons cliquables. **COMPLETED**.
- [x] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
- Déclencheurs : événement (`page.created/updated/deleted/moved`, `collection.*`) / cron (`*/N`, minute fixe, `@hourly`, `@daily`) / bouton
- Conditions combinables : eq, neq, contains, not_contains, is_empty, is_not_empty, changed
- Actions : webhook (X-FlowDeck-Secret), set_property (validé), create_page (interpolation `[[prop]]`/`{{title}}`), notify
- Tables `automations` + `automation_runs` (migration v5), scheduler de fond (60 s), historique des exécutions
- [x] **Buttons** — boutons cliquables déclenchant des actions
- Bloc `button` dans l'éditeur (menu slash) + picker d'automation inline
- Endpoint `/api/automations/{id}/run` (exempt CSRF) + UI Settings → Automations
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
> **Objectif** : fondations de production — design system, sécurité, infra, forge.
> **COMPLETED**.
**Design system**
- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
**Sécurité & Utilisateur**
- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
**Infrastructure**
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
- [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
- [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
**Forge integration**
- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
**Tests (cibles)**
- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
- [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
**✅ Validation (2026-09-11)** :
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
- `pytest -q` (suite complète) → **397 passed**
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
**Complétion du 2026-09-11** :
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)
> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables
> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**.
- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`)
- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-<id>` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
- [x] **WebSocket gateway** — endpoint `WS /ws/pages/{id}`, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
- [x] **Merge de modifications** — diffusion des ops de blocs insert/update/delete/move avec **last-write-wins par bloc** + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
- [x] **Duplicates** — `POST /board/api/pages/{id}/duplicate` (copie profonde des blocs) + `POST /db/{id}/duplicate` (vues + propriétés + pages) ; entrées « Duplicate » dans les menus `...`
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
Détails livrés :
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
### v5.6.0 — Import de données (étendu) ✅ (2026-09-13)
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
> (Markdown/CSV/Notion, déjà dans v5.4.0). **COMPLETED**.
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
#### Phase 0 — Socle d'import unifié (prérequis) ✅
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
- [x] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
- [x] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
- [x] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
- [x] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
- [x] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
- [x] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
- [x] **Idempotence / déduplication** + reprise sur erreur partielle
- [x] **Tests** — harnais d'import + fixtures par source
- [x] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort ✅
- [x] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
- [x] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
- [x] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
- [x] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
- [x] **Google Keep** — Takeout JSON + HTML
- [x] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅
- [x] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
- [x] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
- [x] **Google Sheets** — export CSV/XLSX
- [x] **JSON générique** — mapping configurable (JSONPath → propriétés)
#### Phase 3 — Documents & bureautique — effort moyen ✅
- [x] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
- [x] **Google Docs** — Takeout `.docx`/HTML
- [x] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
- [x] **PDF** — extraction texte + images (fidélité limitée)
#### Phase 4 — Signets, dev & divers ✅
- [x] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
- [x] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
- [x] **Pocket** (CSV/HTML)
- [x] **Readwise** (highlights CSV/Markdown)
- [x] **Shaarli** (API/export JSON) → bookmark cards
- [x] **Calendrier `.ics`** (Google/Outlook/Apple)
- [x] **OPML** (flux/outlines)
- [x] **Standard Notes / autres**
#### Phase 5 — Durcissement & finition ✅
> **Objectif** : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).
- [x] **Import incrémental / re-sync** — modes `skip` / `update` (upsert des pages + lignes par titre) / `duplicate`, via `import_items`
- [x] **Import de dépôt forge** — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
- [x] **Import par URL / web clipper** — `POST /api/import/url` : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
- [x] **Lot multi-fichiers + file d'attente** — `POST /api/import/run-batch` + file d'attente UI (statut par fichier)
- [x] **Relations Notion** — `POST /api/import/relations/resolve` : colonnes texte référençant une autre collection → propriétés `relation` (ids de pages) ; auto-exécuté après un import Notion
- [x] **Reprise / erreurs partielles + rapport exportable** — import qui continue par page en cas d'erreur (`status: partial`, liste `errors`) ; rapport JSON téléchargeable (`GET /api/import/jobs/{id}/report` + bouton UI)
- [x] **Valeurs de propriétés par id** — correction : les lignes importées stockent les valeurs par id de propriété (rendu correct dans les vues DB)
#### Priorisation
| Ordre | Phase | Effort | Impact |
|---|---|---|---|
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
### v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. **COMPLETED**.
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
Détails livrés :
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
Détails livrés :
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
- **Version** — 5.11.7
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0
- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success)
- [ ] **Table of contents** — auto-généré depuis les headings
- [ ] **Math equations** — LaTeX / KaTeX inline + block
- [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel)
- [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes)
### v4.2.0 — Export
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor)
- [ ] **Export PDF** — mise en page fidèle, table des matières
- [ ] **Export HTML** — site statique standalone
### v4.3.0 — Collaboration
- [ ] **Inline comments** — commentaires sur sélection de texte
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
- [ ] **Email notifications** — changements, mentions
### v4.4.0 — Embeds & Rich Media
- [ ] **Embeds** — YouTube, Figma, Google Maps, Twitter, etc.
- [ ] **Image lightbox** — clic pour agrandir, navigation
- [ ] **Bookmark cards** — aperçu riche des liens (OG metadata)
### v4.5.0 — Kanban Adaptable
- [ ] Colonnes configurables par utilisateur
- [ ] Cartes configurables (choisir propriétés affichées)
- [ ] Couverture de carte (image, icône, couleur)
- [ ] WIP limits par colonne
### v4.6.0 — Calendar & Meetings
- [ ] Vue Calendrier drag & drop
- [ ] Récurrence d'événements
- [ ] Template Meeting Notes
### v4.7.0 — AI Assistant
- [ ] Intégration LLM pour écriture/résumé/traduction
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
- [ ] Auto-complétion
### v4.8.0 — Command Palette & Recherche
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
- [ ] **Quick actions** — navigation, création, commandes
### v4.9.0 — Automations
- [ ] **Database automations** — if-this-then-that
- [ ] **Buttons** — cliquables déclenchant actions
### v4.10.0 — Database Avancée
- [ ] Inline databases dans n'importe quelle page
- [ ] Templates de database (Project tracker, CRM…)
- [ ] Validation des propriétés (required, unique, min/max)
---
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
## v5.0.0 — Pro (futur)
## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**.
- [ ] **PWA** — Progressive Web App, offline support
- [ ] **SSO/SAML** — enterprise authentication
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique** — REST API + webhooks documentés
- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs
- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
## v5.14.0 — Synced blocks ✅ (2026-09-15)
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
> plusieurs pages, édité une fois). **COMPLETED**.
- [x] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
- [x] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
- [x] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
---
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
- [x] **Doc** — section `/help` « Offline mode (PWA) »
---
## v6.0.0 — Pro (futur)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note |
|---------|----------|------|
| Webhooks sortants | `services/webhook_outbound.py`, `routers/workspace.py` (`/workspace/webhooks`) | CRUD + dispatch d'événements — base pour automations/API publique |
| API publique + tokens | `routers/public.py` / `public_api.py`, test `test_public_api_token` | À formaliser dans v5.2.0 et documenter pour v6.0.0 |
---
## 🎯 Ordre de priorité recommandé (état 2026-09)
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
---
## Résumé des phases
@@ -761,11 +278,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ⬜
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
Collaboration Realtime, de bloc (undo/ (embed, + DB
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
v4.0.2 ✅ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜
Quality Blocks Export Collab Notion Pro
& Tests enrichis PDF/MD @mentions Parity (futur)
```
*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
*Dernière mise à jour: 2026-07-18 — Roadmap restructuré, focus MVP, stratégie de branches Gitea*
-1
View File
@@ -1 +0,0 @@
6.0.0
-2
View File
@@ -1,5 +1,3 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
+1 -1
View File
@@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider):
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": redirect_uri or self.redirect_uri,
"redirect_uri": self.redirect_uri,
},
headers={"Accept": "application/json"},
)
+8 -123
View File
@@ -1,43 +1,26 @@
"""FlowDeck — Session management with signed cookies."""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
import json
from datetime import datetime, timedelta
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings
logger = logging.getLogger(__name__)
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
class SessionManager:
"""Manages user sessions via signed cookies (v5.2.0: revocable).
Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
Revoking that row instantly invalidates the cookie (checked in
``decode_session``). Legacy cookies without a ``sid`` stay valid.
"""
"""Manages user sessions via signed cookies."""
@staticmethod
def create_session(user_data: dict, request=None) -> str:
"""Create a signed session cookie value.
``request`` is optional — when provided the session is recorded in the
``user_sessions`` table (ip + user agent) and becomes revocable.
"""
def create_session(user_data: dict) -> str:
"""Create a signed session cookie value."""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
user_id = user_data.get("id")
if user_id:
sid = str(uuid4())
payload["sid"] = sid
_record_session(sid, user_id, request)
return _serializer.dumps(payload)
@staticmethod
@@ -45,65 +28,10 @@ class SessionManager:
"""Decode and validate a session cookie. Returns user data or None."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
return payload.get("user")
except (BadSignature, SignatureExpired):
return None
sid = payload.get("sid") or ""
if sid and not _session_active(sid):
# Revoked or deleted session → treat as logged out.
return None
if sid:
_touch_session(sid)
return payload.get("user")
@staticmethod
def session_id(cookie: str) -> str | None:
"""Return the session id embedded in a cookie (or None)."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7)
return payload.get("sid")
except (BadSignature, SignatureExpired):
return None
@staticmethod
def list_sessions(user_id: int) -> list[dict]:
"""All recorded sessions for a user (for the Settings UI)."""
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
"FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
@staticmethod
def revoke_session(sid: str) -> bool:
"""Revoke a session row. Returns True if a row was updated."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
)
conn.commit()
return cur.rowcount > 0
@staticmethod
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
"""Re-sign a cookie keeping its session id (used after profile edits)."""
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
user_id = user_data.get("id")
if user_id:
if sid is None:
sid = str(uuid4())
_record_session(sid, user_id, request)
payload["sid"] = sid
return _serializer.dumps(payload)
@staticmethod
def store_token(user_id: int, gitea_token: str) -> None:
"""Store a user's Gitea OAuth token in SQLite."""
@@ -130,53 +58,10 @@ class SessionManager:
return row["gitea_token"] if row else None
def _record_session(sid: str, user_id: int, request) -> None:
ip = ""
ua = ""
if request is not None:
ip = request.client.host if getattr(request, "client", None) else ""
ua = (request.headers.get("user-agent", "") or "")[:500]
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
(sid, user_id, ip, ua),
)
conn.commit()
except Exception as exc: # table may not exist in very old installs
logger.debug("session record skipped: %s", exc)
def _session_active(sid: str) -> bool:
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT revoked FROM user_sessions WHERE id=?", (sid,)
).fetchone()
return bool(row and not row["revoked"])
except Exception:
# No table / DB unavailable → keep the cookie valid (fail-open-safe).
return True
def _touch_session(sid: str) -> None:
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
(sid,),
)
conn.commit()
except Exception:
pass
# FastAPI dependency
async def get_current_user(request) -> dict | None:
"""FastAPI dependency: extract current user from session cookie."""
from fastapi import Request
session = request.cookies.get("flowdeck_session")
if session:
return SessionManager.decode_session(session)
+2 -45
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -25,9 +24,8 @@ class Settings(BaseSettings):
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = ""
# OAuth2
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
# Webhook
webhook_base_url: str = "http://localhost:8080"
@@ -50,47 +48,6 @@ class Settings(BaseSettings):
sync_interval: int = 60
gitea_cache_ttl: int = 30
# Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
backup_enabled: bool = True
backup_dir: str = "/data/backups"
backup_interval_hours: int = 24
backup_keep: int = 30
# Forge projects sync (v5.2.0) — periodic refresh of `projects` table
project_sync_enabled: bool = True
project_sync_interval_hours: int = 1
# Reminders (v5.8.0) — background scan for due date reminders
reminders_enabled: bool = True
reminder_scan_interval_seconds: int = 60
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
# email notifications are skipped (only in-app notifications are delivered).
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "FlowDeck <[email protected]>"
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True
llm_provider: str = "offline" # any id from llm_client.PROVIDERS
# (openai, anthropic, mistral, cohere,
# google, groq, deepseek, openrouter,
# nvidia, together, perplexity, xai,
# qwencloud, minimax, morph, fireworks,
# cerebras, sambanova, chutes, xiaomi,
# sealion, sensenova, ollama, offline)
llm_model: str = "gpt-4o"
llm_api_key: str = ""
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
agent_max_iterations: int = 12
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+1 -361
View File
@@ -192,7 +192,7 @@ def init_db():
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
icon TEXT DEFAULT 'file',
icon TEXT DEFAULT '📄',
position INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES collection_pages(id),
gitea_issue_id INTEGER,
@@ -462,366 +462,6 @@ def init_db():
""")
conn.commit()
# v4.1.0: Data Sources & Linked Databases
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
source_name TEXT DEFAULT '',
is_linked BOOLEAN NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, source_collection_id)
)
""")
# Add workspace_id to collections if missing (v4.1.0 migration)
try:
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
except sqlite3.OperationalError:
pass
# Add created_by to collections if missing
try:
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
except sqlite3.OperationalError:
pass
conn.commit()
# v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs)
try:
conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)")
except sqlite3.OperationalError:
pass
# v4.2.0: Collection Templates (enhanced) + Dashboards
# Add description, is_recurring, recurrence_rule to page_templates
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
# Dashboard: combinaison de vues/widgets sur une page
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Dashboard',
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
# v4.4.0: Tasks & Dependencies
# Add is_task flag to collections
try:
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Dependencies table: bloque/bloqué par with auto-shift config
conn.execute("""
CREATE TABLE IF NOT EXISTS page_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'blocks',
auto_shift TEXT DEFAULT 'overlap',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, dependency_id, dependency_type)
)
""")
conn.commit()
# v4.5.0: Sprints
conn.execute("""
CREATE TABLE IF NOT EXISTS sprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
goal TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'planning',
auto_complete BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS sprint_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
status_at_start TEXT DEFAULT '',
velocity_points INTEGER DEFAULT 1,
UNIQUE(sprint_id, page_id)
)
""")
conn.commit()
# v4.6.0: Sidebar customization config per user
try:
conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'")
except sqlite3.OperationalError:
pass
conn.commit()
# ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════
# Notifications table (mentions, comments, page changes)
conn.execute("""
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users(id),
ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page'
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
resource_type TEXT NOT NULL DEFAULT 'page',
resource_id INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL DEFAULT '',
is_read INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)"
)
# Notification email preferences (JSON: {"comments": true, "mentions": true})
try:
conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'")
except sqlite3.OperationalError:
pass
# Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to
# collection_pages, which prevents using page-editor (pages) ids. Rebuild it so
# it can hold page comments with optional inline anchors, while preserving data.
# target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id.
# anchor_block_id = block id; anchor_start/anchor_end = text selection offsets.
_cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()]
if "target_type" not in _cols:
try:
conn.execute("""
CREATE TABLE comments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER,
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL DEFAULT '',
parent_id INTEGER,
resolved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
target_type TEXT NOT NULL DEFAULT 'page',
target_id INTEGER NOT NULL DEFAULT 0,
anchor_block_id TEXT,
anchor_start INTEGER,
anchor_end INTEGER
)
""")
conn.execute(
"""INSERT INTO comments_new
(id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id)
SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at,
'collection_page', COALESCE(page_id, 0)
FROM comments"""
)
conn.execute("DROP TABLE comments")
conn.execute("ALTER TABLE comments_new RENAME TO comments")
except sqlite3.OperationalError:
pass
conn.commit()
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
conn.execute("""
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
icon TEXT DEFAULT '🤖',
agent_type TEXT NOT NULL DEFAULT 'personal',
description TEXT DEFAULT '',
system_instructions TEXT DEFAULT '',
model TEXT DEFAULT 'gpt-4o',
scope_json TEXT NOT NULL DEFAULT '{}',
trigger_json TEXT NOT NULL DEFAULT '{}',
approval_mode TEXT NOT NULL DEFAULT 'auto',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT DEFAULT 'New conversation',
status TEXT NOT NULL DEFAULT 'idle',
context_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls_json TEXT DEFAULT '[]',
model TEXT,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'success',
undo_snapshot_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
executed_by INTEGER REFERENCES users(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL,
description TEXT DEFAULT '',
prompt_template TEXT NOT NULL,
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_triggers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL DEFAULT 'manual',
config_json TEXT NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT 1,
last_fired_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
snippet TEXT DEFAULT '',
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
# Created lazily (no seed) so .env stays the default until an admin saves
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
conn.execute("""
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'offline',
model TEXT DEFAULT '',
api_key TEXT DEFAULT '',
api_base TEXT DEFAULT '',
verified INTEGER NOT NULL DEFAULT 0,
verified_model TEXT DEFAULT '',
verified_at TIMESTAMP,
last_error TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
# Each user can save several providers with their own key/base + the
# live model list fetched from the provider (models_json cache).
conn.execute("""
CREATE TABLE IF NOT EXISTS user_llm_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
api_base TEXT NOT NULL DEFAULT '',
default_model TEXT DEFAULT '',
models_json TEXT NOT NULL DEFAULT '[]',
verified INTEGER NOT NULL DEFAULT 0,
verified_model TEXT DEFAULT '',
verified_at TIMESTAMP,
last_error TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, provider)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
# v4.12: provider activation/verification — a provider is only offered in
# the Agent UI once it is configured AND its connection test succeeded.
for col, ddl in (
("verified", "INTEGER NOT NULL DEFAULT 0"),
("verified_model", "TEXT DEFAULT ''"),
("verified_at", "TIMESTAMP"),
("last_error", "TEXT DEFAULT ''"),
):
try:
conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass # column already exists
for col, ddl in (
("verified", "INTEGER NOT NULL DEFAULT 0"),
("verified_model", "TEXT DEFAULT ''"),
("verified_at", "TIMESTAMP"),
("last_error", "TEXT DEFAULT ''"),
):
try:
conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass # column already exists
# Migration: per-conversation provider/model override columns
for col in ("provider", "model"):
try:
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass # column already exists
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
conn.commit()
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
from app.migrations import apply_migrations
apply_migrations(conn)
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
# schema exists without depending on the FastAPI lifespan startup.
from app.services.webhook_outbound import init_webhook_tables
init_webhook_tables()
@contextmanager
def get_conn():
+20 -115
View File
@@ -1,51 +1,22 @@
"""FlowDeck — Kanban léger intégré à Gitea."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
from app.routers import (
admin,
agent,
api,
auth,
board,
collections,
dashboard,
export,
library,
my_tasks,
notes,
onboarding,
projects,
public_api,
search,
security,
sharing,
sidebar_config,
sync,
webhooks,
workspace,
)
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.notifications import router as notifications_router
from app.routers.realtime import router as realtime_router
from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -68,47 +39,13 @@ async def lifespan(_app: FastAPI):
(admin_hash,)
)
conn.commit()
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler())
# ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler())
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler())
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try:
yield
finally:
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
try:
await task
except asyncio.CancelledError:
pass
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="6.0.0",
version="4.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -124,8 +61,6 @@ app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
app.include_router(projects.router)
app.include_router(projects.backups_router)
app.include_router(api.router)
app.include_router(webhooks.router)
app.include_router(collections.router)
@@ -137,56 +72,26 @@ app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router)
app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
app.include_router(agent.router)
app.include_router(search.router)
app.include_router(security.router)
app.include_router(onboarding.router)
app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
return {
"name": "FlowDeck",
"short_name": "FlowDeck",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
}
# ═══════════ API aliases (v4.0.1) ═══════════
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
import secrets
from fastapi.responses import JSONResponse
token = secrets.token_hex(32)
response = JSONResponse({"csrf_token": token})
response.set_cookie(
"csrf_token", token,
httponly=False, samesite="lax", max_age=86400, path="/",
)
return response
@app.get("/api/pages")
async def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency."""
@@ -233,10 +138,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
if "/api" in request.url.path:
"""Styled 404 page matching the FlowDeck dark theme."""
from fastapi.responses import HTMLResponse
# API routes should get JSON, not HTML
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Not found"}, status_code=404)
from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
return HTMLResponse(NOT_FOUND_HTML, status_code=404)
-2
View File
@@ -1,4 +1,2 @@
"""FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware
__all__ = ["CSRFMiddleware"]
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.requests import Request
class CSRFMiddleware(BaseHTTPMiddleware):
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+1 -6
View File
@@ -68,7 +68,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; "
"connect-src 'self' https: wss:; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
@@ -115,11 +115,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Respect the global rate-limit toggle (disabled in tests/local).
from app.config import settings
if not settings.rate_limit_enabled:
return await call_next(request)
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
return await call_next(request)
-711
View File
@@ -1,711 +0,0 @@
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
This replaces the previous "ad-hoc" approach where every new schema change was
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
table now records the highest applied migration; the full baseline schema
(created idempotently by `init_db`) is treated as version 1, and any incremental
change is expressed as an ordered, versioned step below and applied exactly once.
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
``ALTER TABLE``) so it is safe even if partially re-run.
"""
from __future__ import annotations
import logging
import sqlite3
from typing import Callable
logger = logging.getLogger(__name__)
# The full baseline schema created by `app.db::init_db()` is "version 1".
BASELINE_VERSION = 1
# (version, name, apply_fn). Kept sorted by version at registration time.
MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
def register(version: int, name: str) -> Callable:
"""Decorator registering a migration in the ordered registry."""
if any(v == version for v, _, _ in MIGRATIONS):
raise ValueError(f"Duplicate migration version {version}")
def decorator(fn: Callable[[sqlite3.Connection], None]):
MIGRATIONS.append((version, name, fn))
MIGRATIONS.sort(key=lambda item: item[0])
return fn
return decorator
def _ensure_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
).fetchone()
return int(row[0])
def fts5_available() -> bool:
"""True when the bundled SQLite ships the FTS5 extension."""
probe = sqlite3.connect(":memory:")
try:
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
return True
except sqlite3.OperationalError:
return False
finally:
probe.close()
def apply_migrations(conn: sqlite3.Connection) -> int:
"""Seal the baseline schema (version 1) and apply pending migrations.
Returns the resulting schema version.
"""
_ensure_table(conn)
applied = current_version(conn)
if applied < BASELINE_VERSION:
# The pre-existing schema (already created by init_db) is our baseline.
conn.execute(
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
(BASELINE_VERSION, "baseline"),
)
conn.commit()
applied = BASELINE_VERSION
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
# ═══════════════════════════════════════════════════════════════════════════
# Migrations
# ═══════════════════════════════════════════════════════════════════════════
@register(2, "missing indexes")
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
for ddl in (
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
):
conn.execute(ddl)
@register(3, "full-text search (FTS5)")
def _migration_fts5(conn: sqlite3.Connection) -> None:
"""Create a full-text index over pages (title + content) for the command palette.
Kept in sync via row-level triggers on the ``pages`` table so page
insert/update/delete are reflected immediately. Skips gracefully if the
bundled SQLite lacks FTS5 (search then falls back to LIKE).
"""
if not fts5_available():
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
return
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, title, body)
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
END
"""
)
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
# to external-content/contentless FTS5 tables).
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
END
"""
)
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
INSERT INTO pages_fts(rowid, title, body)
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
END
"""
)
# Backfill the index from any rows that already exist.
conn.execute(
"""
INSERT INTO pages_fts(rowid, title, body)
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
WHERE deleted_at IS NULL
"""
)
@register(5, "automations (v5.1.0 rules engine)")
def _migration_automations(conn: sqlite3.Connection) -> None:
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
condition + action) and clickable buttons that trigger actions.
``automations`` — the rules (event/cron/button trigger, optional
condition JSON, actions JSON, run counters).
``automation_runs`` — execution history for auditing and the Settings UI.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
collection_id INTEGER, -- optional scope (event triggers)
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
enabled BOOLEAN NOT NULL DEFAULT 1,
created_by INTEGER,
last_run_at TIMESTAMP,
run_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
trigger_source TEXT NOT NULL DEFAULT 'event',
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
detail TEXT NOT NULL DEFAULT '',
collection_id INTEGER,
page_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
)
@register(6, "v5.2.0: api tokens, user sessions, projects")
def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"""v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
the forge-agnostic ``projects`` table.
``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
powering the public API (/api/v1) and Settings UI.
``user_sessions`` — one row per signed session cookie; revocation here
instantly kills the corresponding cookie.
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
if "id" not in _pcols:
conn.execute(
"""
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'API token',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
last_used_at TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
if "id" not in _scols:
conn.execute(
"""
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY, -- session id (cookie payload)
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_address TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "id" not in _projcols:
conn.execute(
"""
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
owner TEXT NOT NULL DEFAULT '',
forge_id TEXT DEFAULT '',
clone_url TEXT DEFAULT '',
default_branch TEXT DEFAULT '',
language TEXT DEFAULT '',
description TEXT DEFAULT '',
last_synced_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(proj_type, owner, name)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
)
@register(7, "v5.4.0/v5.5.0: page versions, cover + icon")
def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"""v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon).
``page_versions`` — undoable version snapshots for block-editor pages
(NOT tied to ``collection_pages`` like the legacy
``page_history`` table). One row per save with the
full block list + title so the UI can browse/restore.
``pages.cover_url`` — image cover shown above the page title.
``pages.page_icon`` — emoji / icon label shown next to the title.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
@register(8, "v5.6.0: custom workspace emojis")
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS custom_emojis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
)
@register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
# Seed built-in templates (idempotent: only missing names are inserted).
from app.services.db_templates import SEED_TEMPLATES
for tpl in SEED_TEMPLATES:
conn.execute(
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
VALUES (?, ?, ?, ?)""",
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
__import__("json").dumps(tpl.get("schema", []))),
)
@register(9, "v5.6.0: import items (dedup) + import jobs")
def _migration_import_framework(conn: sqlite3.Connection) -> None:
"""Unified import framework (Phase 0).
``import_items`` — one row per imported page, keyed by workspace + source +
external id, so re-importing the same vault is idempotent.
``import_jobs`` — background import job status/history for UI polling.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER,
source TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
page_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, source, external_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_items_lookup "
"ON import_items(workspace_id, source, external_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS import_jobs (
id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued',
error TEXT NOT NULL DEFAULT '',
report_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
)
@register(10, "v5.7.0: property groups, per-user views, row covers")
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
"""v5.7.0 — Database Avancée (Pt. 2).
``collection_properties.group_name`` — groups properties into collapsible
sections in the table header (Notion property groups).
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
a shared/legacy view visible to everyone, otherwise it is personal to a user.
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@register(11, "v5.8.0: reminder log + user timezones")
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"""v5.8.0 — Calendrier & Rappels.
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
a reminder fires exactly once even across restarts.
``users.timezone`` — personal IANA timezone used for "today" in calendar
views and reminder firing (empty = UTC).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
occurrence_date TEXT NOT NULL,
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, occurrence_date)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@register(12, "v5.8.0: enrich Meeting notes template")
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
Notes text properties. Only refreshed when the row still matches the old
built-in schema (user edits are never clobbered)."""
import json as _json
row = conn.execute(
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
).fetchone()
if not row:
return
try:
schema = _json.loads(row[0] or "[]")
except (ValueError, TypeError):
return
names = [p.get("name") for p in schema]
if "Agenda" in names or "Notes" in names:
return
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
return # customised — leave alone
idx = names.index("Action items")
schema[idx:idx] = [
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
]
conn.execute(
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
(_json.dumps(schema),),
)
conn.execute(
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
("Notes de réunion avec participants, agenda, notes et actions.",),
)
@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates")
def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
"""v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage.
``pages.is_locked`` — read-only page (locker/admin can unlock).
``pages.locked_by`` — user that locked the page.
``pages.full_width`` — per-page full-width layout toggle.
``pages.font_small`` — per-page compact typography toggle.
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
if "full_width" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0")
if "font_small" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0")
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_global_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📄',
description TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)"
)
@register(14, "v5.13.0: fix stale LLM provider api_base values")
def _migration_fix_llm_api_bases(conn: sqlite3.Connection) -> None:
"""Clear `api_base` values that freeze a provider URL to a wrong/default value.
The Agent "Test connection" uses the per-user (or global) stored `api_base`
when present, so an old/incorrect value (e.g. Mistral `…/v2`, Cohere
`…/v2`, Google native `/v1beta`) keeps failing even after `PROVIDERS` is
corrected. Two kinds of rows are reset to the provider default:
* known-wrong legacy bases from earlier releases;
* a stored base identical to the current provider default (a no-op
override that would block future default changes).
"""
from app.services.llm_client import PROVIDERS
legacy: dict[str, set[str]] = {
"mistral": {"https://api.mistral.ai/v2"},
"cohere": {"https://api.cohere.com/v2", "https://api.cohere.com/v1",
"https://api.cohere.ai/v2"},
"google": {"https://generativelanguage.googleapis.com/v1beta"},
"perplexity": {"https://api.perplexity.ai/v1"},
"chutes": {"https://api.chutes.ai/v1"},
"sensenova": {"https://token.sensenova.cn/v1"},
"ltx": {"https://api.ltx.io/v1"},
"memtensor": {"https://memos.memtensor.cn/api/openmem/v1"},
}
for provider, (base, _) in PROVIDERS.items():
if base:
legacy.setdefault(provider, set()).update({base, base.rstrip("/")})
for provider, bases in legacy.items():
variants = {b for b in bases if b}
variants |= {b.rstrip("/") for b in bases if b}
for table in ("user_llm_keys", "llm_config"):
for value in variants:
conn.execute(
f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?",
(provider, value),
)
@register(15, "v5.14.0: synced blocks")
def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
"""v5.14.0 — Synced blocks: a block created once, displayed &
edited across multiple pages.
``synced_blocks`` — source-of-truth content for synced blocks.
``page_blocks`` — per-page reference to a synced block
(so each page can independently decide to use/unsync).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS synced_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id),
workspace TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_synced_blocks_ws ON synced_blocks(workspace)"
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_synced_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
synced_block_id INTEGER NOT NULL REFERENCES synced_blocks(id) ON DELETE CASCADE,
block_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, synced_block_id)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_page ON page_synced_blocks(page_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
)
@register(16, "v6.0.0: offline sync queue")
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
"""v6.0.0 — PWA offline support.
``offline_sync_queue`` persists server-side the mutations received from
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
audited/replayed per device.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL,
client_timestamp REAL NOT NULL,
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@register(17, "v6.0.0: sync_version columns")
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
Every write on a page / collection increments its ``sync_version`` so a
reconnecting client can detect edit-edit conflicts via version mismatch.
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
write path (no need to patch dozens of ``UPDATE`` call-sites).
"""
for table in ("pages", "collection_pages", "collections"):
_add_sync_version(conn, table)
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
# `recursive_triggers` is OFF by default, so the inner UPDATE never
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
AFTER UPDATE ON pages
FOR EACH ROW BEGIN
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
AFTER UPDATE ON collection_pages
FOR EACH ROW BEGIN
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
AFTER UPDATE ON collections
FOR EACH ROW BEGIN
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
+12 -9
View File
@@ -1,11 +1,14 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
# ── File Save ────────────────────────────────────────────────
class FileSaveRequest(BaseModel):
@@ -13,7 +16,7 @@ class FileSaveRequest(BaseModel):
path: str = Field(..., min_length=1, description="File path in the repository")
content: str = Field(..., description="File content (UTF-8 encoded)")
message: str = Field(default="Update via FlowDeck", description="Commit message")
sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
@model_validator(mode="after")
def validate_path_extension(self):
@@ -31,10 +34,10 @@ class UploadValidationResult(BaseModel):
size: int
extension: str
valid: bool
error: str | None = None
error: Optional[str] = None
def validate_upload_request(file: UploadFile) -> str | None:
def validate_upload_request(file: UploadFile) -> Optional[str]:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
@@ -63,12 +66,12 @@ class IssueCreateRequest(BaseModel):
class IssueUpdateRequest(BaseModel):
"""Request model for updating a Gitea issue (partial update)."""
title: str | None = Field(default=None, max_length=500)
body: str | None = Field(default=None)
state: str | None = Field(default=None, pattern=r"^(open|closed)$")
labels: str | None = Field(default=None, description="Comma-separated label IDs")
milestone: str | None = Field(default=None)
assignee: str | None = Field(default=None)
title: Optional[str] = Field(default=None, max_length=500)
body: Optional[str] = Field(default=None)
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
milestone: Optional[str] = Field(default=None)
assignee: Optional[str] = Field(default=None)
# ── Card Move ────────────────────────────────────────────────
+3 -3
View File
@@ -1,7 +1,7 @@
"""FlowDeck — Standardized response models."""
from __future__ import annotations
from typing import Any
from typing import Any, Optional
from pydantic import BaseModel
@@ -13,7 +13,7 @@ class ErrorResponse(BaseModel):
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
"""
error: str
detail: str | None = None
detail: Optional[str] = None
model_config = {
"json_schema_extra": {
@@ -29,7 +29,7 @@ class SuccessResponse(BaseModel):
SuccessResponse(status="ok", data={"issue_id": 42})
"""
status: str = "ok"
data: dict[str, Any] | None = None
data: Optional[dict[str, Any]] = None
model_config = {
"json_schema_extra": {
+3 -3
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Admin API: users, roles, stats, audit."""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
router = APIRouter(tags=["admin"], prefix="/api/admin")
@@ -48,9 +48,9 @@ async def list_users(_admin=Depends(admin_required)):
@router.post("/users")
async def create_user(request: Request, _admin=Depends(admin_required)):
"""Create a new user (admin only)."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -80,9 +80,9 @@ 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)):
"""Update a user: name, email, password, admin status, active status."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
-1030
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -4,15 +4,16 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
@@ -149,7 +150,7 @@ async def move_card(
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id)
filtered_names = [name for name in current_labels if name not in status_labels]
filtered_names = [l for l in current_labels if l not in status_labels]
filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs
@@ -293,7 +294,7 @@ async def create_issue(
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue(
@@ -345,7 +346,7 @@ async def update_issue_api(
if state:
kwargs["state"] = state
if labels:
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone)
if assignee:
@@ -388,7 +389,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
# Get checklists from local DB
with get_conn() as conn:
+17 -38
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Request, Query
from fastapi.responses import RedirectResponse, HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
@@ -13,24 +13,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
def get_redirect_uri(request: Request) -> str:
"""OAuth redirect URI for this request.
Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the
provider's OAuth app). Otherwise it is derived from the request so it always
matches the URL the user actually used: scheme from `X-Forwarded-Proto`
(reverse proxies) falling back to the request scheme, host from
`X-Forwarded-Host` falling back to the `Host` header.
"""
if settings.oauth_redirect_uri:
return settings.oauth_redirect_uri
proto = request.headers.get("x-forwarded-proto", "")
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback"
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -162,20 +144,19 @@ async def login(request: Request, provider: str = Query("gitea")):
# Encode auth mode in state to survive session loss during OAuth redirect
signed_state = f"{state}:{mode}" if mode else state
request.session["oauth_mode"] = mode
# Redirect URI derived from the incoming request (scheme-aware); stored in
# session so the callback reuses the EXACT same URI for token exchange
redirect_uri = get_redirect_uri(request)
request.session["oauth_redirect_uri"] = redirect_uri
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link"))
# Dynamic redirect URI based on incoming Host header
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
return RedirectResponse(url=auth_url, status_code=302)
@router.post("/register")
async def register(request: Request):
"""Register a new local account."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -210,7 +191,7 @@ async def register(request: Request):
user_data = dict(user)
# Log login
_log_login(user_data["id"], request)
session = SessionManager.create_session(user_data, request)
session = SessionManager.create_session(user_data)
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -220,12 +201,10 @@ async def register(request: Request):
@router.post("/local-login")
async def local_login(request: Request):
"""Login with email + password."""
import time
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.password_utils import is_locked, verify_password
from app.password_utils import verify_password, is_locked
from fastapi.responses import JSONResponse
import json, time
try:
body = await request.json()
except Exception:
@@ -267,7 +246,7 @@ async def local_login(request: Request):
(str(time.time()), ud["id"]),
)
conn.commit()
session = SessionManager.create_session(ud, request)
session = SessionManager.create_session(ud)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -303,10 +282,10 @@ async def callback(
if not oauth_provider:
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
# Exchange code for token — reuse the redirect URI from the authorize step
# (stored in session), falling back to deriving it from this request
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
# Exchange code for token — use dynamic redirect URI matching the authorize step
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri)
if not token_data:
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
@@ -365,7 +344,7 @@ async def callback(
user_data = dict(user) if user else oauth_user
# Create session
session = SessionManager.create_session(user_data, request)
session = SessionManager.create_session(user_data)
_log_login(user_data["id"], request)
response = RedirectResponse(url="/workspaces", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
-174
View File
@@ -1,174 +0,0 @@
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import get_page_context, run_automation
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
TRIGGER_TYPES = ("event", "cron", "button")
def _json_or_dumps(val, default="[]"):
"""Store JSON string columns without double-encoding."""
if val is None:
return default
if isinstance(val, str):
try:
json.loads(val)
return val
except (TypeError, json.JSONDecodeError):
return json.dumps(val)
return json.dumps(val)
def _current_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user if user and user.get("id") else {}
def _validate_payload(body: dict) -> None:
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
trigger_type = body.get("trigger_type", "event")
if trigger_type not in TRIGGER_TYPES:
raise HTTPException(status_code=400, detail="invalid trigger_type")
if trigger_type == "event" and not body.get("event"):
raise HTTPException(status_code=400, detail="event required for event trigger")
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
for key in ("condition_json", "actions_json"):
val = body.get(key, "[]")
try:
if isinstance(val, str):
json.loads(val)
else:
json.dumps(val)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations")
async def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
return {"automations": items}
@router.post("/workspace/automations")
async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
user = _current_user(request)
by = user.get("id") or 1
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO automations
(workspace, name, trigger_type, event, cron_expression, collection_id,
condition_json, actions_json, enabled, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
body.get("workspace", "") or "",
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
by,
),
)
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "status": "created"}
@router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
return dict(row)
@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 {}
_validate_payload(body)
with get_conn() as conn:
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
conn.execute(
"""UPDATE automations SET
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
auto_id,
),
)
conn.commit()
return {"id": auto_id, "status": "updated"}
@router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
return {"id": auto_id, "status": "deleted"}
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
page_id = body.get("page_id") if isinstance(body, dict) else None
collection_id = body.get("collection_id") if isinstance(body, dict) else None
context = {"collection_id": collection_id, "page_id": page_id}
if page_id:
context.update(get_page_context(int(page_id), collection_id or 0))
result = await run_automation(automation_id, trigger_source, context)
result["automation_id"] = automation_id
return result
@router.post("/workspace/automations/{auto_id}/run")
async def run_automation_endpoint(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "manual", body)
@router.post("/api/automations/{auto_id}/run")
async def run_automation_button(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "button", body)
@router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=?
ORDER BY created_at DESC, id DESC LIMIT ?""",
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
+71 -1061
View File
File diff suppressed because it is too large Load Diff
-184
View File
@@ -1,184 +0,0 @@
"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions.
Comments live in the existing `comments` table, extended with a target_type /
target_id pair and inline anchors (anchor_block_id + text offsets). Mentions
written in a comment body automatically notify the mentioned users.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import notifications as notif
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collaboration"], prefix="/api")
def _current_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _page_url(page_id: int) -> str:
from app.config import settings
return f"{settings.app_base_url}/pages/{page_id}"
def _serialize(rows):
out = []
for r in rows:
d = dict(r)
d["author"] = {
"id": r["author_id"],
"login": r["author_login"],
"full_name": r["author_name"],
"avatar_url": r["author_avatar"],
"avatar_color": r["author_color"],
}
for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"):
d.pop(k, None)
out.append(d)
return out
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page."""
_current_user(request)
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT c.*, c.user_id AS author_id, u.login AS author_login,
u.full_name AS author_name, u.avatar_url AS author_avatar,
u.avatar_color AS author_color
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.target_type='page' AND c.target_id=?
ORDER BY c.created_at ASC, c.id ASC""",
(page_id,),
).fetchall()
return {"page_id": page_id, "comments": _serialize(rows)}
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
"""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")
anchor_block = body.get("anchor_block_id")
anchor_start = body.get("anchor_start")
anchor_end = body.get("anchor_end")
# normalize empty anchor → page-level comment
if not anchor_block or anchor_start is None or anchor_end is None:
anchor_block, anchor_start, anchor_end = None, None, None
elif int(anchor_start) == int(anchor_end):
anchor_block, anchor_start, anchor_end = None, None, None
parent_id = body.get("parent_id")
uid = user["id"]
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
)
cur = conn.execute(
"""INSERT INTO comments
(page_id, user_id, body, parent_id, target_type, target_id,
anchor_block_id, anchor_start, anchor_end)
VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""",
(page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end),
)
comment_id = cur.lastrowid
conn.commit()
# Notify users @-mentioned in the comment (skip the author).
url = _page_url(page_id)
title = f"New comment on “{page['title']}”"
message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}"
notif.process_mentions(
text, uid, "mention", title, message,
"page", page_id, url, conn=conn,
)
conn.commit()
return {"id": comment_id, "status": "created"}
@router.post("/pages/{page_id}/mentions")
async def notify_page_mentions(request: Request, page_id: int):
"""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()
if not page:
raise HTTPException(404, "Page not found")
url = _page_url(page_id)
mentioned = notif.process_mentions(
text, user["id"], "mention", f"You were mentioned in “{page['title']}”",
f"{user.get('full_name') or user.get('login')} mentioned you on a page.",
"page", page_id, url, conn=conn,
)
conn.commit()
return {"mentioned": mentioned}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
"""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,)
).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"]:
raise HTTPException(403, "Not allowed to edit this comment")
if "body" in body and body.get("body") is not None:
conn.execute(
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(body["body"].strip(), comment_id),
)
if "resolved" in body and body.get("resolved") is not None:
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
(1 if body["resolved"] else 0, comment_id))
conn.commit()
return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"]:
# allow page "owners" — fall back to a simple ownership rule for now
raise HTTPException(403, "Not allowed to delete this comment")
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
+38 -1355
View File
File diff suppressed because it is too large Load Diff
+85 -630
View File
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
"""FlowDeck — custom workspace emojis (v5.6.0).
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
"""
from __future__ import annotations
import datetime
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
router = APIRouter(tags=["emojis"])
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _active_ws(request: Request) -> int:
"""Workspace id from the active-workspace cookie, fallback 1."""
try:
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
return 1
@router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis."""
ws_id = _active_ws(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
(ws_id,),
).fetchall()
return {"status": "ok", "emojis": [dict(r) for r in rows]}
@router.post("/api/custom-emojis")
async def create_custom_emoji(request: Request):
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
form = await request.form()
name = (form.get("name") or "").strip()[:40] or "emoji"
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
(folder / final).write_bytes(await upload.read())
url = f"/api/files/{ws_id}/{final}"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
(ws_id, name, url),
)
conn.commit()
emoji_id = cur.lastrowid
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
@router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request)
with get_conn() as conn:
row = conn.execute(
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
(emoji_id, ws_id),
).fetchone()
if not row:
raise HTTPException(404, "emoji not found")
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
conn.commit()
try:
fname = (row["url"] or "").rsplit("/", 1)[-1]
if fname:
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
except OSError:
pass
return {"status": "ok", "id": emoji_id}
-93
View File
@@ -1,93 +0,0 @@
"""FlowDeck — Export endpoints (v4.7.0).
Routes /api/export/* — generate Markdown, HTML, PDF and static-site (zip)
exports server-side for a given page.
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from app.db import get_conn
from app.services.export import (
build_static_site_bytes,
page_to_markdown,
page_to_pdf_bytes,
page_to_standalone_html,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["export"], prefix="/api/export")
def _load_page_or_404(page_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
return dict(row)
def _download_header(filename: str, media_type: str) -> dict:
ascii_name = re.sub(r"[^\x00-\x7F]", "_", filename)
quoted = filename.replace('"', '')
return {
"Content-Disposition": f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quoted}',
"Cache-Control": "no-store",
"Content-Type": media_type,
}
def _safe_filename(page: dict, ext: str) -> str:
title = (page.get("title") or "Untitled").strip() or "Untitled"
title = re.sub(r'[\\/:*?"<>|]+', "_", title)
return f"{title}.{ext}"
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
headers = _download_header(filename, "text/markdown")
return Response(content=md.encode("utf-8"), status_code=200, headers=headers)
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
page = _load_page_or_404(page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
headers = _download_header(filename, "text/html")
return Response(content=html.encode("utf-8"), status_code=200, headers=headers)
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
except ImportError:
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
except Exception as exc: # noqa: BLE001
logger.error("PDF export failed for page %s: %s", page_id, exc)
raise HTTPException(status_code=500, detail="PDF generation failed") from exc
filename = _safe_filename(page, "pdf")
headers = _download_header(filename, "application/pdf")
return Response(content=pdf_bytes, status_code=200, headers=headers)
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
page = _load_page_or_404(page_id)
site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
filename = f"{title}_site.zip"
headers = _download_header(filename, "application/zip")
return Response(content=site_bytes, status_code=200, headers=headers)
+9 -8
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401.
Only returns a client if the user has personally connected their Gitea
account (OAuth token). No fallback to admin token — each user must link
their own Gitea account to see Gitea projects.
@@ -92,6 +92,7 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
@router.put("/projects/{owner}/{repo}/file")
async def save_file(request: Request, owner: str, repo: str):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
try:
body = await request.json()
@@ -139,6 +140,7 @@ async def upload_file(request: Request, owner: str, repo: str):
@router.delete("/projects/{owner}/{repo}/file")
async def delete_file(request: Request, owner: str, repo: str):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
@@ -160,8 +162,8 @@ async def get_labels(request: Request, owner: str, repo: str):
try:
labels = await gitea.get_labels(owner, repo)
return {"labels": [
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
for lbl in labels
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
for l in labels
]}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
@@ -212,9 +214,9 @@ async 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):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
@@ -253,9 +255,9 @@ async 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):
"""Update a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
@@ -327,8 +329,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
for label in labels:
name = label.get("name", "")
color = label.get("color", "#787774")
if not name:
continue
if not name: continue
existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone()
-378
View File
@@ -1,378 +0,0 @@
"""FlowDeck — unified import API (v5.6.0, Phase 0/1/2).
Exposes the importer registry, a dry-run preview, a synchronous run and an
optional background job with polling. Works with the existing workspace cookie
(``flowdeck_workspace``) and session.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.importers import (
get_job,
list_jobs,
list_sources,
parse_upload,
preview_result,
resolve_relations,
run_import,
start_import_job,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/import", tags=["import"])
page_router = APIRouter(tags=["import"])
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
def _current_user(request: Request) -> dict:
return SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {}
@page_router.get("/import", response_class=HTMLResponse)
async def import_page(request: Request):
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
user = _current_user(request)
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
return HTMLResponse(content=env.get_template("import.html").render(user=user))
def _workspace(request: Request) -> tuple[int | None, str]:
"""Resolve (workspace_id, login) from the workspace cookie + session."""
ws_id: int | None = None
cookie = request.cookies.get("flowdeck_workspace", "")
try:
value = int(cookie)
if value > 0:
ws_id = value
except (ValueError, TypeError):
pass
user = _current_user(request)
login = user.get("login", "") if user else ""
return ws_id, login
async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "File too large (max 200 MB)")
source_id = form.get("source") or None
return filename, data, source_id
@router.get("/sources")
async def import_sources(request: Request):
"""List every available importer for the UI source picker."""
return {"sources": list_sources()}
@router.post("/preview")
async def import_preview(request: Request):
"""Dry-run: parse the upload and describe what would be created."""
filename, data, source_id = await _read_upload(request)
imp, result = parse_upload(filename, data, source_id)
if imp is None:
raise HTTPException(400, "Format non reconnu — choisissez une source")
out = preview_result(result)
out["detected_source"] = imp.source_id
out["source_label"] = imp.label
return out
@router.post("/run")
async def import_run(request: Request):
"""Import an upload (synchronously, or as a background job when async=true)."""
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "File too large (max 200 MB)")
source_id = form.get("source") or None
parent_id = _int_or_none(form.get("parent_id"))
target = _int_or_none(form.get("target_collection_id"))
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
async_mode = str(form.get("async", "false")).lower() in ("true", "1", "yes")
mapping = _parse_mapping(form.get("mapping"))
mode = _parse_mode(form.get("mode"))
ws_id, login = _workspace(request)
if async_mode:
job = start_import_job(
filename=filename, data=data, source_id=source_id,
workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
return {"status": "queued", "job_id": job["id"]}
imp, result = parse_upload(filename, data, source_id)
if imp is None:
raise HTTPException(400, "Format non reconnu — choisissez une source")
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
report["detected_source"] = imp.source_id
if imp.source_id == "notion":
with get_conn() as conn:
report["relations"] = resolve_relations(conn, ws_id)
for page_id in report.get("page_ids", [])[:100]:
try:
await fire_event("page.created", {"page_id": page_id, "title": "", "workspace": login})
except Exception: # noqa: BLE001 - events are best-effort
pass
return report
@router.post("/forge")
async def import_forge(request: Request):
"""Import a forge repo's issues (+ labels/milestones) into collections."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
provider = str(body.get("provider") or "gitea").lower()
owner = str(body.get("owner") or "").strip()
repo = str(body.get("repo") or "").strip()
if not owner or not repo:
raise HTTPException(400, "owner and repo are required")
state = str(body.get("state") or "all")
include_labels = bool(body.get("include_labels", True))
include_milestones = bool(body.get("include_milestones", True))
ws_id, login = _workspace(request)
if provider == "gitea":
from app.services.gitea_client import get_user_gitea_client
from app.services.importers.forge import GiteaForgeAdapter
client = get_user_gitea_client(request)
if client is None:
raise HTTPException(400, "Gitea non connecté")
adapter = GiteaForgeAdapter(client)
elif provider == "github":
from app.services.github_adapter import GitHubAdapter
token = _user_oauth_token(request, "github")
if not token:
raise HTTPException(400, "GitHub non connecté")
adapter = GitHubAdapter(token)
else:
raise HTTPException(400, "provider must be 'gitea' or 'github'")
from app.services.importers.forge import fetch_forge_issues
result = await fetch_forge_issues(
adapter, owner, repo, provider=provider, state=state,
include_labels=include_labels, include_milestones=include_milestones,
)
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
)
report["detected_source"] = f"forge:{provider}"
return report
@router.post("/forge-repo")
async def import_forge_repo(request: Request):
"""Import a forge repo's text files as pages (folder hierarchy preserved)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
provider = str(body.get("provider") or "gitea").lower()
owner = str(body.get("owner") or "").strip()
repo = str(body.get("repo") or "").strip()
if not owner or not repo:
raise HTTPException(400, "owner and repo are required")
path = str(body.get("path") or "")
max_files = min(int(body.get("max_files") or 200), 1000)
ws_id, login = _workspace(request)
adapter = _forge_adapter(request, provider)
from app.services.importers.forge_repo import fetch_forge_repo
result = await fetch_forge_repo(
adapter, owner, repo, provider=provider, path=path, max_files=max_files,
)
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
report["detected_source"] = f"forge-repo:{provider}"
return report
@router.post("/url")
async def import_url(request: Request):
"""Web clipper: fetch a URL and create a page (bookmark card + content)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = str(body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url is required")
ws_id, login = _workspace(request)
from app.services.importers.url_fetch import fetch_url_result
try:
result = await fetch_url_result(url)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
if not result.pages:
raise HTTPException(422, "; ".join(result.warnings) or "Page introuvable")
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
report["detected_source"] = "url"
return report
@router.post("/run-batch")
async def import_run_batch(request: Request):
"""Import several uploaded files sequentially, returning one report each."""
form = await request.form()
uploads = form.getlist("file")
if not uploads:
raise HTTPException(400, "file field required")
source_id = form.get("source") or None
parent_id = _int_or_none(form.get("parent_id"))
target = _int_or_none(form.get("target_collection_id"))
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
mode = _parse_mode(form.get("mode"))
mapping = _parse_mapping(form.get("mapping"))
ws_id, login = _workspace(request)
results: list[dict] = []
summary = {"files": 0, "pages_created": 0, "rows_created": 0, "errors": 0}
for upload in uploads:
filename = (getattr(upload, "filename", "") or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
results.append({"filename": filename, "report": {"status": "error",
"errors": [{"title": filename, "error": "File too large"}]}})
summary["errors"] += 1
continue
imp, result = parse_upload(filename, data, source_id)
if imp is None:
results.append({"filename": filename, "report": {"status": "error",
"errors": [{"title": filename, "error": "Format non reconnu"}]}})
summary["errors"] += 1
continue
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
report["detected_source"] = imp.source_id
results.append({"filename": filename, "report": report})
summary["files"] += 1
summary["pages_created"] += report.get("pages_created", 0)
summary["rows_created"] += report.get("rows_created", 0)
summary["errors"] += len(report.get("errors", []))
return {"status": "ok", "summary": summary, "results": results}
@router.post("/relations/resolve")
async def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request)
with get_conn() as conn:
return resolve_relations(conn, ws_id)
@router.get("/jobs")
async def import_jobs(request: Request):
return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}")
async def import_job(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
return job
@router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str):
"""Download a job's import report as JSON."""
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
payload = json.dumps(job.get("report") or {}, ensure_ascii=False, indent=2)
return Response(
content=payload,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="import-{job_id}.json"'},
)
def _forge_adapter(request: Request, provider: str):
if provider == "gitea":
from app.services.gitea_client import get_user_gitea_client
from app.services.importers.forge import GiteaForgeAdapter
client = get_user_gitea_client(request)
if client is None:
raise HTTPException(400, "Gitea non connecté")
return GiteaForgeAdapter(client)
if provider == "github":
from app.services.github_adapter import GitHubAdapter
token = _user_oauth_token(request, "github")
if not token:
raise HTTPException(400, "GitHub non connecté")
return GitHubAdapter(token)
raise HTTPException(400, "provider must be 'gitea' or 'github'")
def _int_or_none(value) -> int | None:
try:
ivalue = int(value)
return ivalue if ivalue > 0 else None
except (ValueError, TypeError):
return None
def _parse_mapping(value) -> dict[str, str] | None:
if not value:
return None
try:
parsed = json.loads(value)
except (ValueError, TypeError):
return None
if isinstance(parsed, dict):
return {str(k): str(v) for k, v in parsed.items() if v}
return None
def _parse_mode(value) -> str | None:
mode = str(value or "").strip().lower()
return mode if mode in ("skip", "update", "duplicate") else None
def _user_oauth_token(request: Request, provider: str) -> str:
user = _current_user(request)
if not user or not user.get("id"):
return ""
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=? "
"ORDER BY updated_at DESC LIMIT 1",
(user["id"], provider),
).fetchone()
return row["access_token"] if row else ""
+24 -103
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
@@ -59,12 +60,9 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
elif content_format == "file":
icon = "📄"
fn = title.lower()
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
else:
icon = "📝"
@@ -72,7 +70,6 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"id": page_id,
"title": title,
"icon": icon,
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_label": _source_label(source_type, workspace),
@@ -87,8 +84,6 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"url": url,
"content_format": content_format,
"favorited": bool(db_row.get("favorited", 0)),
"share_mode": db_row.get("share_mode", "private"),
"tags": [],
}
@@ -103,30 +98,7 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
def _attach_tags(items: list) -> list:
"""Batch-load page tags for library items."""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
with get_conn() as conn:
rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
ids,
).fetchall()
tag_map: dict = {}
for r in rows:
tag_map.setdefault(r["page_id"], []).append({
"id": r["id"], "name": r["name"], "color": r["color"],
})
for it in items:
it["tags"] = tag_map.get(it["id"], [])
return items
return [_build_item(dict(r), uid) for r in rows]
def _enrich_children(items: list) -> list:
@@ -152,7 +124,7 @@ def _enrich_children(items: list) -> list:
BASE_SELECT = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
"p.parent_id, p.share_mode, p.parent_section"
)
@@ -201,10 +173,10 @@ async def library_favorites(
uid = _get_user_id(request)
query = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
if tree:
@@ -225,45 +197,13 @@ async def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
dir: str = Query(default="all"),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
if dir not in ("made", "received", "all"):
dir = "all"
uid = _get_user_id(request)
# Page ids the user shares toward others (nominal page_shares) or receives
with get_conn() as conn:
made_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
(uid,),
).fetchall()
recv_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
(uid,),
).fetchall()
made_ids = {r[0] for r in made_rows}
recv_ids = {r[0] for r in recv_rows}
conds: list[str] = []
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if dir in ("all", "made"):
conds.append("p.share_mode != 'private'")
if dir in ("all", "made") and made_ids:
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
params.extend(made_ids)
if dir in ("all", "received") and recv_ids:
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
params.extend(recv_ids)
if dir == "received" and not recv_ids:
return {"items": []}
query = (
f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
if tree:
query += " AND p.parent_id IS NULL"
query, params = _apply_source_filter(query, params, source_type)
@@ -273,11 +213,6 @@ async def library_shared(
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it in items:
sid = it["id"]
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
is_recv = sid in recv_ids
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
_enrich_children(items)
return {"items": items}
@@ -335,7 +270,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
uid = _get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
@@ -361,12 +296,9 @@ async def library_local_workspace_children(item_id: int, request: Request):
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
@@ -392,8 +324,6 @@ async def library_local_workspace_children(item_id: int, request: Request):
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
@@ -474,12 +404,9 @@ async def library_local_workspace(
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
# Check for children
child_count = conn.execute(
@@ -505,8 +432,6 @@ async def library_local_workspace(
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
@@ -514,14 +439,10 @@ async def library_local_workspace(
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
if not size_bytes: return ""
if size_bytes < 1024: return f"{size_bytes} B"
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
+3 -3
View File
@@ -4,12 +4,12 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
user_login = user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
-2
View File
@@ -22,7 +22,6 @@ async def get_notes(request: Request, owner: str, repo: str):
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -46,7 +45,6 @@ async def save_notes(request: Request, owner: str, repo: str):
conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
-152
View File
@@ -1,152 +0,0 @@
"""FlowDeck — Notifications API (v4.9.0 collaboration)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
def _current_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("")
async def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first."""
user = _current_user(request)
with get_conn() as conn:
rows = conn.execute(
"""SELECT n.*, a.login AS actor_login, a.full_name AS actor_name,
a.avatar_url AS actor_avatar, a.avatar_color AS actor_color
FROM notifications n
LEFT JOIN users a ON n.actor_id = a.id
WHERE n.user_id=?
ORDER BY n.created_at DESC, n.id DESC LIMIT ?""",
(user["id"], limit),
).fetchall()
unread = conn.execute(
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
(user["id"],),
).fetchone()["c"]
return {
"notifications": [dict(r) for r in rows],
"unread": unread,
}
@router.get("/unread-count")
async def unread_count(request: Request):
"""Unread count for the topbar badge."""
user = _current_user(request)
with get_conn() as conn:
c = conn.execute(
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
(user["id"],),
).fetchone()["c"]
return {"unread": c}
@router.post("/read")
async def mark_read(request: Request):
"""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:
conn.execute(
"UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?",
(nid, user["id"]),
)
else:
conn.execute(
"UPDATE notifications SET is_read=1 WHERE user_id=?",
(user["id"],),
)
conn.commit()
return {"status": "ok"}
@router.post("/read-all")
async def mark_all_read(request: Request):
"""Mark all notifications as read."""
return await mark_read(request)
@router.get("/prefs")
async def get_prefs(request: Request):
"""Return the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
return {"prefs": notif.get_user_prefs(user["id"])}
@router.post("/prefs")
async def set_prefs(request: Request):
"""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:
prefs[key] = bool(body[key])
notif.set_user_prefs(user["id"], prefs)
return {"status": "ok", "prefs": prefs}
@router.get("/timezone")
async def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
from app.services.recurrence import common_timezones
return {"timezone": tz, "zones": common_timezones()}
@router.post("/timezone")
async def set_timezone(request: Request):
"""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):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
with get_conn() as conn:
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
conn.commit()
return {"status": "ok", "timezone": tz}
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
_current_user(request)
q = (q or "").strip()
with get_conn() as conn:
if q:
like = f"%{q}%"
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users WHERE login LIKE ? OR full_name LIKE ?
ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""",
(like, like, q, q),
).fetchall()
else:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users ORDER BY login LIMIT 20"""
).fetchall()
return {"users": [dict(r) for r in rows]}
-135
View File
@@ -1,135 +0,0 @@
"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
First-launch experience: create workspace → connect a forge (optional) →
create the first project (welcome page), then land in the app.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["onboarding"])
WORKSPACE_COOKIE = "flowdeck_workspace"
def _require_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
return user
@router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
with get_conn() as conn:
ws_count = conn.execute(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
).fetchone()[0]
if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("welcome.html")
return HTMLResponse(content=template.render(
user=user,
gitea_url_configured=_forge_configured("gitea"),
github_url_configured=_forge_configured("github"),
))
def _forge_configured(provider: str) -> bool:
try:
from app.auth.providers import get_provider
return get_provider(provider) is not None
except Exception:
return False
# ═══════════ Onboarding API ═══════════
@router.post("/api/onboarding/workspace")
async def onboarding_create_workspace(request: Request):
"""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:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
(name, user["id"]),
)
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
(cur.lastrowid, user["id"]),
)
conn.commit()
ws_id = cur.lastrowid
response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
@router.post("/api/onboarding/project")
async def onboarding_create_project(request: Request):
"""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")
with get_conn() as conn:
ws = None
if workspace_id:
ws = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, user["id"]),
).fetchone()
if not ws:
ws = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user["id"],),
).fetchone()
if not ws:
raise HTTPException(status_code=400, detail="Create a workspace first")
blocks = [
{"id": "1", "type": "heading_1", "content": title},
{"id": "2", "type": "paragraph",
"content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
{"id": "3", "type": "paragraph",
"content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
{"id": "4", "type": "paragraph",
"content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
]
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
"VALUES (?, ?, ?, ?, 'blocks', 'Private')",
(user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
-67
View File
@@ -1,67 +0,0 @@
"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.services import projects as projects_svc
from app.services.backup import backup_db, list_backups
logger = logging.getLogger(__name__)
router = APIRouter(tags=["projects"], prefix="/api/projects")
backups_router = APIRouter(tags=["backups"])
def _require_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only")
return user
@router.get("")
async def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)}
@router.post("")
async def create_project(request: Request):
"""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")
project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
return {"status": "ok", "project": project}
@router.post("/sync")
async def sync_projects(request: Request):
"""Trigger an immediate forge sync for every connected account."""
_require_admin(request)
stats = await projects_svc.sync_all_projects()
return {"status": "ok", "stats": stats}
# ═══════════ Backups (admin) ═══════════
@backups_router.post("/api/settings/backups/run")
async def run_backup_now(request: Request):
"""Admin: create a database backup immediately."""
_require_admin(request)
filename = backup_db()
if not filename:
raise HTTPException(status_code=400, detail="Backups disabled or no database file")
return {"status": "ok", "filename": filename}
@backups_router.get("/api/settings/backups")
async def admin_list_backups(request: Request):
"""Admin: list stored backups."""
_require_admin(request)
return {"backups": list_backups()}
+10 -46
View File
@@ -1,13 +1,12 @@
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
"""FlowDeck — Public API router (v2.1.0)."""
from __future__ import annotations
import hashlib
import json
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi import APIRouter, Request, HTTPException, Header, Depends
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -15,63 +14,28 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _token_owner(token: str) -> dict | None:
"""Resolve an api_tokens row by its sha256 hash (revoked → None)."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
(_hash_token(token),),
).fetchone()
if not row:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
)
conn.commit()
return dict(row)
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if row:
return token
owner = _token_owner(token)
if not owner:
if not row:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token.
When an authenticated session is present the token is bound to that user
(revocable from Settings → API tokens); otherwise a legacy shared token is
created for backward compatibility.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
"""Generate a public API access token."""
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
if user and user.get("id"):
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
else:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
-49
View File
@@ -1,49 +0,0 @@
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway /ws/pages/{page_id}.
Auth via cookie session (flowdeck_session). Rooms in-memory par page.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, WebSocket
from starlette.websockets import WebSocketDisconnect
from app.auth.session import SessionManager
from app.services.realtime_server import manager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["realtime"])
@router.websocket("/ws/pages/{page_id}")
async def ws_page(websocket: WebSocket, page_id: int):
await websocket.accept()
user = SessionManager.decode_session(
websocket.cookies.get("flowdeck_session", "")
)
if not user or not user.get("id"):
try:
await websocket.close(code=4401)
except Exception:
pass
return
conn = await manager.connect(websocket, page_id, user)
if not conn:
return
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except (TypeError, ValueError):
continue
await manager.handle(conn, msg)
except WebSocketDisconnect:
pass
except Exception as e: # noqa: BLE001
logger.debug("ws closed: %s", e)
finally:
await manager.disconnect(conn)
-27
View File
@@ -1,27 +0,0 @@
"""FlowDeck — unified search router (v5.0.0).
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
editor pages and databases scoped to the current user's accessible workspaces.
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from app.auth.session import SessionManager
from app.services.search import search as search_service
router = APIRouter(tags=["search"])
@router.get("/api/search")
async def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
data = search_service(q, user_id=user_id)
return {
"query": q,
"pages": data["pages"],
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
}
-121
View File
@@ -1,121 +0,0 @@
"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
Backend for the Settings → API tokens / Sessions UI.
"""
from __future__ import annotations
import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["security"], prefix="/api/settings")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _current_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
return user["id"]
# ═══════════ API tokens ═══════════
@router.get("/tokens")
async def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, token_prefix, last_used_at, revoked, created_at "
"FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
(uid,),
).fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.post("/tokens")
async def create_token(request: Request):
"""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:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(uid, name[:80], _hash_token(token), token[:12]),
)
conn.commit()
tid = cur.lastrowid
return {"id": tid, "name": name, "token": token,
"note": "Copy this token now — it won't be shown again."}
@router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete)."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Token not found")
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
conn.commit()
return {"status": "revoked"}
# ═══════════ Active sessions ═══════════
@router.get("/sessions")
async def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
sessions = SessionManager.list_sessions(uid)
now = __import__("datetime").datetime.now()
for s in sessions:
s["is_current"] = (s["id"] == current_sid)
# A session older than 7 days is implicitly expired (cookie max-age).
created = s.get("created_at") or ""
try:
from datetime import datetime
created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
s["expired"] = (now - created_dt).days >= 7
except Exception:
s["expired"] = False
return {"sessions": sessions}
@router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Session not found")
SessionManager.revoke_session(sid)
# Also wipe the OAuth state cookie if the current session was revoked.
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
if current_sid == sid:
try:
request.session.clear()
except Exception:
pass
return {"status": "revoked"}
+9 -62
View File
@@ -6,10 +6,10 @@ import re
import unicodedata
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request, HTTPException
from app.auth.session import SessionManager
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
@@ -61,41 +61,16 @@ async def share_page(page_id: int, request: Request):
if not target:
raise HTTPException(404, "Target user not found")
# Upsert to avoid duplicates: update the existing permission if the same
# target (user or email) is already shared on this page.
target_row = None
if target_user_id:
target_row = conn.execute(
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
(page_id, target_user_id),
).fetchone()
elif email:
target_row = conn.execute(
"""SELECT id FROM page_shares
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
(page_id, email.strip()),
).fetchone()
if target_row:
conn.execute(
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
(permission, user["id"], target_row["id"]),
)
share_id = target_row["id"]
else:
if not email:
email = ""
cur = conn.execute(
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
VALUES (?, ?, ?, ?, ?)""",
(page_id, target_user_id, email.strip(), permission, user["id"]),
)
share_id = cur.lastrowid
cur = conn.execute(
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
VALUES (?, ?, ?, ?, ?)""",
(page_id, target_user_id, email, permission, user["id"]),
)
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
conn.commit()
return {
"id": share_id,
"id": cur.lastrowid,
"page_id": page_id,
"shared_with_user_id": target_user_id,
"shared_with_email": email,
@@ -104,34 +79,6 @@ async def share_page(page_id: int, request: Request):
}
@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):
"""Change the permission level of an existing share entry."""
_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"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
(share_id, page_id),
).fetchone()
if not row:
raise HTTPException(404, "Share entry not found")
conn.execute(
"UPDATE page_shares SET permission=? WHERE id=?",
(permission, share_id),
)
conn.commit()
return {"status": "updated", "share_id": share_id, "permission": permission}
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
@@ -201,7 +148,7 @@ async def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
user = _require_auth(request)
with get_conn() as conn:
page = conn.execute(
-115
View File
@@ -1,115 +0,0 @@
"""FlowDeck — Sidebar customization API (v4.6.0)."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
def _get_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
DEFAULT_CONFIG = {
"workspace": {"visible": True, "order": 0, "show_count": None},
"gitea": {"visible": True, "order": 1, "show_count": None},
"meetings": {"visible": True, "order": 2, "show_count": 5},
"recents": {"visible": True, "order": 3, "show_count": 10},
"favorites": {"visible": True, "order": 4, "show_count": 10},
"agents": {"visible": True, "order": 5, "show_count": None},
"shared": {"visible": True, "order": 6, "show_count": 10},
"published": {"visible": True, "order": 7, "show_count": 10},
"private": {"visible": True, "order": 8, "show_count": None},
}
@router.get("/config")
async def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config."""
user = _get_user(request)
with get_conn() as conn:
row = conn.execute(
"SELECT sidebar_config FROM users WHERE id=?", (user["id"],)
).fetchone()
if not row:
return {"config": DEFAULT_CONFIG}
raw = row["sidebar_config"]
if not raw:
return {"config": DEFAULT_CONFIG}
try:
stored = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {"config": DEFAULT_CONFIG}
# Merge with defaults to ensure all keys exist
merged = dict(DEFAULT_CONFIG)
merged.update(stored)
return {"config": merged}
def get_sidebar_config_sync(user_id: int) -> dict:
"""Synchronous helper to get sidebar config (used during template rendering)."""
with get_conn() as conn:
row = conn.execute(
"SELECT sidebar_config FROM users WHERE id=?", (user_id,)
).fetchone()
if not row:
return dict(DEFAULT_CONFIG)
raw = row["sidebar_config"]
if not raw:
return dict(DEFAULT_CONFIG)
try:
stored = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return dict(DEFAULT_CONFIG)
merged = dict(DEFAULT_CONFIG)
merged.update(stored)
return merged
@router.put("/config")
async def save_sidebar_config(request: Request):
"""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):
raise HTTPException(status_code=400, detail="config object is required")
# Merge with defaults to ensure validity
merged = dict(DEFAULT_CONFIG)
for key, val in config.items():
if key in DEFAULT_CONFIG and isinstance(val, dict):
merged[key] = {
"visible": val.get("visible", DEFAULT_CONFIG[key]["visible"]),
"order": val.get("order", DEFAULT_CONFIG[key]["order"]),
"show_count": val.get("show_count", DEFAULT_CONFIG[key]["show_count"]),
}
elif key not in DEFAULT_CONFIG:
# Allow new custom sections
merged[key] = val
with get_conn() as conn:
conn.execute(
"UPDATE users SET sidebar_config=? WHERE id=?",
(json.dumps(merged), user["id"]),
)
conn.commit()
return {"status": "ok", "config": merged}
-88
View File
@@ -1,88 +0,0 @@
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
session (``flowdeck_session`` cookie).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.services.sync_engine import SyncEngine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
_engine = SyncEngine()
def _user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("/delta")
async def sync_delta(
request: Request,
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
workspace_id: int = Query(default=None),
):
"""Pull server-side changes since `since` (for the given workspace)."""
user = _user(request)
if workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
result = await _engine.get_delta(user["id"], since, workspace_id)
if result.get("error") == "forbidden":
return JSONResponse({"detail": "Forbidden"}, status_code=403)
return result
@router.post("/batch")
async def sync_batch(request: Request):
"""Apply a batch of offline mutations and return per-mutation results."""
user = _user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
mutations = body.get("mutations") or []
device_id = body.get("device_id") or "unknown"
if not isinstance(mutations, list) or not mutations:
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
result = await _engine.apply_batch(user["id"], mutations, device_id)
result["server_time"] = SyncEngine._now_epoch()
return result
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request)
from app.db import get_conn
with get_conn() as conn:
if not SyncEngine._can_access(conn, user["id"], workspace_id):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
pending = conn.execute(
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
(user["id"],),
).fetchone()["n"]
last = conn.execute(
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
"WHERE user_id=? AND status='synced'",
(user["id"],),
).fetchone()["last"]
return {
"pending_count": pending,
"last_sync": last,
"is_syncing": False,
"server_time": SyncEngine._now_epoch(),
"workspace_id": workspace_id,
}
+4 -4
View File
@@ -1,12 +1,12 @@
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
from __future__ import annotations
import hashlib
import hmac
import json
import hmac
import hashlib
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request, HTTPException
from app.config import settings
from app.db import get_conn
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
return {"status": "ok", "webhook": result}
except Exception as e:
logger.error("Failed to register webhook: %s", e)
raise HTTPException(status_code=500, detail=str(e)) from e
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status/{owner}/{repo}")
+13 -299
View File
@@ -5,14 +5,12 @@ import csv
import io
import json
import logging
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request, HTTPException
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.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
@@ -232,9 +230,8 @@ async def create_db_template(request: Request):
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
(body.get("name", "Template"), body.get("description", ""),
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@@ -242,16 +239,22 @@ async def create_db_template(request: Request):
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
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()
if not tmpl:
raise HTTPException(404, "Template not found")
collection_id = create_from_template(conn, name, dict(tmpl))
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
conn.commit()
return {"collection_id": collection_id, "name": name, "status": "created"}
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
@@ -290,298 +293,9 @@ 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", {
"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):
"""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)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
"""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": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
"""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)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
return {"id": did, "status": "deleted"}
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
).fetchall()
sprints = []
for r in rows:
s = dict(r)
# Count pages in sprint
count = conn.execute(
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
).fetchone()["cnt"]
s["page_count"] = count
sprints.append(s)
return {"sprints": sprints}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint(request: Request, collection_id: int):
"""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", "")
if not name or not start_date or not end_date:
raise HTTPException(400, "name, start_date, end_date are required")
goal = body.get("goal", "")
status = body.get("status", "planning")
auto_complete = int(body.get("auto_complete", 1))
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
(collection_id, name, start_date, end_date, goal, status, auto_complete),
)
conn.commit()
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):
"""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)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
name = body.get("name", sprint["name"])
start_date = body.get("start_date", sprint["start_date"])
end_date = body.get("end_date", sprint["end_date"])
goal = body.get("goal", sprint["goal"])
status = body.get("status", sprint["status"])
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
conn.execute(
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
(name, start_date, end_date, goal, status, auto_complete, sid),
)
conn.commit()
return {"id": sid, "status": "updated"}
@router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
conn.commit()
return {"id": sid, "status": "deleted"}
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
"""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")
velocity_points = body.get("velocity_points", 1)
status_at_start = body.get("status_at_start", "")
with get_conn() as conn:
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
try:
conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint") from None
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
).fetchone()
if not existing:
raise HTTPException(404, "Assignment not found")
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
conn.commit()
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
pages = conn.execute(
"""SELECT sp.velocity_points, cp.property_values_json
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
WHERE sp.sprint_id=?""", (sid,)
).fetchall()
total_points = sum(p["velocity_points"] for p in pages)
completed = 0
for p in pages:
props = json.loads(p["property_values_json"])
for v in props.values():
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
completed += p["velocity_points"]
break
from datetime import date
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
total_days = max((end - start).days, 1)
elapsed = max((today - start).days, 0)
ideal_burn = total_points - (total_points * elapsed / total_days)
return {
"sprint": sprint["name"],
"total_points": total_points,
"completed_points": completed,
"remaining_points": total_points - completed,
"ideal_remaining": round(ideal_burn, 1),
"start_date": sprint["start_date"],
"end_date": sprint["end_date"],
"days_elapsed": elapsed,
"days_total": total_days,
}
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
-456
View File
@@ -1,456 +0,0 @@
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.0).
`objective → comprehension → context → reasoning ↔ action → result`.
The engine drives the LLM (which only emits tool intentions), gates each call
through PermissionManager, executes it via ToolRegistry, journals every action
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
conversation is automatically renamed with a descriptive title derived from its
content so the history stays easy to browse.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.config import settings
from app.db import get_conn
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
MAX_ITERATIONS = 12
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
# when no document is attached to the conversation (generic help / onboarding).
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout.
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections.
- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque.
- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global).
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
# Deterministic auto-title heuristics (used when no real LLM is configured, and
# as a fallback when the generated title is unusable). Ordered by priority: the
# first matching intent wins.
_TITLE_INTENTS = (
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
"create", "creation")),
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
("Renommage", ("renommer", "renomme", "renommage", "rename")),
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
"met à jour", "mettre à jour", "update", "éditer")),
("Analyse", ("analyser", "analyse")),
("Résumé", ("résumer", "résume", "résumé", "resume")),
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
"organise", "sprint", "agenda")),
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
"trouve", "liste", "lister", "search", "find")),
)
_TITLE_TYPES = (
("collection", "collection", ("collection", "base de données", "database", "db")),
("propriété", "propriété", ("propriété", "property")),
("vue", "vue", (" vue", "view")),
("board", "board", ("board", "kanban")),
("sprint", "sprint", ("sprint",)),
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
("tâche", "tâche", ("tâche", "task", "tache")),
("issue", "issue", ("issue",)),
)
class AgentEngine:
def __init__(self, user_id: int, workspace_id: int | None = None,
llm: LLMClient | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
self.llm = llm or LLMClient()
self.tools = ToolRegistry()
self.ctx = ContextBuilder(user_id, workspace_id)
self.perms = PermissionManager(user_id)
self._tokens = 0
# ── Helpers ──
def _load_agent(self, conversation_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
(conversation_id,),
).fetchone()
if not row:
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
"system_instructions": "", "model": settings.llm_model,
"scope_json": "{}", "approval_mode": "auto"}
return dict(row)
def _build_system_prompt(self, agent: dict, skills=None) -> str:
if skills is None:
skills = []
if isinstance(skills, dict):
skills = [skills]
lines = [
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
f"Workspace courant : {self.workspace_id}.",
]
if agent.get("system_instructions"):
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
# Plusieurs skills peuvent être appliqués au même post : chacun injecte
# son prompt dans les instructions système.
for skill in skills:
if skill:
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
# L'utilisateur peut poser des questions d'aide sans contexte de document ;
# le guide intégré permet d'y répondre (aucun outil requis).
lines.append("\n" + APP_GUIDE)
return "\n".join(lines)
# ── Main run (async generator of SSE events) ──
async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
mentions: list[str] | None = None, files: list[dict] | None = None,
skill_id: int | None = None, skill_ids: list[int] | None = None,
extra_context: str | None = None):
agent = self._load_agent(conversation_id)
scope = json.loads(agent.get("scope_json") or "{}")
approval_mode = agent.get("approval_mode") or "auto"
model = model or agent.get("model") or settings.llm_model
ids = list(skill_ids or [])
if skill_id and skill_id not in ids:
ids.append(skill_id)
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files)
if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
messages = [
{"role": "system", "content": system},
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
]
self._persist_message(conversation_id, "user", objective)
self._update_conversation(conversation_id, status="running")
# Update the history title right away (before the run finishes) and
# refine it once we have the final answer (_autotitle below).
try:
suggested = self._suggest_title(objective, None)
if suggested:
self._update_conversation(conversation_id, title=suggested[:80])
except Exception: # noqa: BLE001 — never break a run because of the title
logger.exception("Auto-title failed for conversation #%s", conversation_id)
tool_schema = self.tools.schema(scope)
final_text = None
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
response = await asyncio.wait_for(
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
timeout=settings.agent_run_timeout_seconds,
)
self._tokens += response.usage.get("total_tokens", 0) or 0
if getattr(response, "notice", ""):
yield self._event("notice", {"message": response.notice})
used_model = getattr(response, "model", "") or used_model
if response.text and response.text.strip():
yield self._event("reasoning", {"content": response.text})
if not response.tool_calls:
messages.append({"role": "assistant", "content": response.text or ""})
final_text = response.text or self._no_tool_message(response)
yield self._event("final", {"content": final_text})
break
# L'API de chat exige que le message assistant qui *annonce* les appels
# d'outils porte les `tool_calls` (avec id), puis que chaque résultat
# d'outil soit fourni avec le `tool_call_id` correspondant. Sans cela
# la passe suivante est refusée par le fournisseur (et l'agent retombait
# silencieusement sur le mock hors-ligne).
tool_specs = []
for idx, call in enumerate(response.tool_calls):
call_id = call.get("id") or f"call_{conversation_id}_{idx}_{self._tokens}"
tool_specs.append({
"id": call_id,
"type": "function",
"function": {
"name": call["name"],
"arguments": call.get("arguments_raw")
or json.dumps(call.get("arguments") or {}, ensure_ascii=False),
},
})
assistant_msg = {"role": "assistant", "content": response.text or ""}
assistant_msg["tool_calls"] = tool_specs
messages.append(assistant_msg)
for idx, call in enumerate(response.tool_calls):
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": f"Permission refusée: {detail}"}, ensure_ascii=False),
})
denied = True
if not denied:
result = await self.tools.execute(tool, args, user_id=self.user_id)
if result.status == "success":
yield self._event("action", {
"tool": tool, "status": result.status,
"target_type": result.target_type, "target_id": result.target_id,
"message": result.message,
})
self._log_action(conversation_id, tool, args, result.data, "success",
target_type=result.target_type, target_id=result.target_id,
undo=result.undo)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
})
else:
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
})
if final_text is None:
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
yield self._event("final", {"content": final_text})
self._persist_message(conversation_id, "assistant", final_text,
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
except Exception as exc: # noqa: BLE001
logger.exception("AgentEngine run failed")
yield self._event("error", {"message": f"Erreur interne: {exc}"})
finally:
self._update_conversation(conversation_id, status="idle")
# ── Skills ──
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
return None
skill = dict(row)
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
if allowed:
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
"\nOutils autorisés: " + ", ".join(allowed)
return skill
# ── Audit & persistence ──
def _log_action(self, conversation_id, tool, args, result, status,
*, target_type="", target_id=None, undo=None, detail=""):
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_actions
(conversation_id, tool_name, target_type, target_id, payload_json,
result_json, status, undo_snapshot_json, executed_by)
VALUES (?,?,?,?,?,?,?,?,?)""",
(conversation_id, tool, target_type,
str(target_id) if target_id is not None else None,
json.dumps(args, ensure_ascii=False),
json.dumps(result, ensure_ascii=False, default=str),
status,
json.dumps(undo or {}, ensure_ascii=False),
self.user_id),
)
conn.commit()
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
with get_conn() as conn:
conn.execute(
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
(conversation_id, role, content, model, tokens),
)
conn.commit()
def _update_conversation(self, conversation_id, *, status=None, title=None):
with get_conn() as conn:
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
if status:
sets.append("status=?")
params.append(status)
if title:
sets.append("title=?")
params.append(title)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
# ── Misc ──
@staticmethod
def _event(etype: str, data: dict) -> dict:
return {"type": etype, **data}
@staticmethod
def _no_tool_message(response) -> str:
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
@staticmethod
def _exc_detail(exc: Exception) -> str:
detail = getattr(exc, "detail", None)
return detail if isinstance(detail, str) else str(exc)
# ── Auto-title (v4.14.0) ──
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
"""Rename the conversation with a descriptive title derived from the
*latest* user request. Runs after every AI call so the history list
always reflects the current topic and stays easy to browse."""
try:
suggested = self._suggest_title(objective, final_text)
if suggested:
self._update_conversation(conversation_id, title=suggested[:80])
except Exception: # noqa: BLE001 — never break a run because of the title
logger.exception("Auto-title failed for conversation #%s", conversation_id)
@classmethod
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
"""Produce a short descriptive title from the user objective (offline-safe)."""
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
if not text:
return "Conversation"
# Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the
# frontend prepends before the real user text.
text = re.sub(
r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*",
"", text,
).strip()
if not text:
return "Conversation"
low = text.lower()
intent = None
for label, words in _TITLE_INTENTS:
if any(w in low for w in words):
intent = label
break
type_label = next(
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
)
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
subject = quotes[0] if quotes else None
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
def _clean(s: str) -> str:
return re.sub(r"\s+", " ", s).strip(" .;:-")
if not subject and type_label:
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
m = re.search(
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
text, re.IGNORECASE,
)
if m:
subject = m.group(1).strip()
if intent and subject:
core = f"{intent} {type_label or 'élément'} « {subject} »" \
if type_label else f"{intent} « {subject} »"
if ws:
core += f" (dans {ws})"
return _clean(core)
generic = _clean(text)
return generic[:70] if generic else "Conversation"
def undo_action(action_id: int) -> bool:
"""Reverse a single agent action using its stored undo snapshot.
Returns True on success. Marks the action row `reverted`.
"""
with get_conn() as conn:
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
if not action:
raise ValueError(f"Action #{action_id} introuvable")
undo = json.loads(action["undo_snapshot_json"] or "{}")
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
if not op or not table or rid is None:
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
if op == "delete":
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
elif op == "softdelete":
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
elif op == "insert":
snapshot = undo.get("snapshot")
if not snapshot:
raise ValueError("Snapshot manquant pour insert")
cols = ", ".join(snapshot.keys())
ph = ", ".join("?" for _ in snapshot)
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
elif op == "update":
snapshot = undo.get("snapshot")
if table == "collection_pages":
conn.execute(
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
)
elif table == "pages":
if isinstance(snapshot, dict):
conn.execute(
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(snapshot.get("content", ""),
snapshot.get("content_format", "markdown"),
snapshot.get("title", ""), rid),
)
else:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(snapshot, rid))
else:
raise ValueError(f"Table non gérée pour rollback: {table}")
else:
raise ValueError(f"Opération de rollback inconnue: {op}")
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
conn.commit()
return True
-330
View File
@@ -1,330 +0,0 @@
"""FlowDeck — AI Writing Assist (v5.9.0).
Headless, tool-free writing helpers used by the editor (slash commands,
inline autocomplete) and the database table (AI property suggestions).
All actions share one entry point, :meth:`AIWritingService.run`, which builds a
tight prompt, calls the configured LLM (or the deterministic offline mock) and
returns plain Markdown. `properties` additionally returns a structured
``suggestions`` mapping so the caller can fill collection properties.
The service never talks to the DB directly — the router resolves the caller's
provider/key and the page context before delegating here.
"""
from __future__ import annotations
import json
import logging
import re
from app.services.llm_client import LLMClient
logger = logging.getLogger(__name__)
WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties")
_MAX_CONTEXT = 20000
# Property name → (type, offline default) used by the deterministic fallback so
# the feature stays useful without a connected provider.
_OFFLINE_PROPERTY_DEFAULTS = (
(("status", "état", "etat", "stage"), "select", "To do"),
(("priority", "priorité", "priorite"), "select", "Medium"),
(("done", "terminé", "termine", "complété", "complete"), "checkbox", False),
(("summary", "résumé", "resume", "description", "notes"), "text", ""),
)
_SYSTEM_WRITING = (
"Tu es l'assistant d'écriture de FlowDeck. "
"Réponds UNIQUEMENT avec le contenu demandé, en Markdown léger "
"(paragraphes, listes à puces, titres si utile). "
"N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte."
)
class AIWritingService:
"""Deterministic, provider-agnostic writing assistant."""
def __init__(self, user_id: int | None = None, provider: str | None = None,
model: str | None = None, api_key: str | None = None,
api_base: str | None = None):
self.user_id = user_id
self.provider = (provider or "").strip().lower() or None
self.model = (model or "").strip() or None
self._api_key = api_key
self._api_base = api_base
# ── LLM plumbing ──
def _client(self) -> LLMClient:
provider = self.provider
api_key = self._api_key
api_base = self._api_base
if provider and self.user_id:
try:
from app.services.llm_config import get_user_llm_key
row = get_user_llm_key(self.user_id, provider)
if row and row.get("api_key"):
api_key = row["api_key"]
api_base = (row.get("api_base") or "").strip() or api_base
except Exception: # noqa: BLE001 — never fail on key lookup
pass
return LLMClient(provider=provider, api_key=api_key, api_base=api_base)
async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]:
llm = self._client()
offline = llm.provider == "offline" or not llm._has_credentials()
user_content = prompt
if context and context.strip():
user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT]
messages = [
{"role": "system", "content": _SYSTEM_WRITING},
{"role": "user", "content": user_content},
]
resp = await llm.complete(messages, model=self.model, tools=None, stream=False)
return (resp.text or "").strip(), (resp.model or self.model or ""), offline
# ── Public API ──
async def run(self, action: str, *, prompt: str = "", context: str = "",
target_language: str = "English", prefix: str = "",
title: str = "", properties: list | None = None) -> dict:
action = (action or "").strip().lower()
if action not in WRITING_ACTIONS:
raise ValueError(f"Action inconnue: {action or '(vide)'}")
if action == "properties":
suggestions = await self.suggest_properties(
context=context, title=title, properties=properties or [])
return {"ok": True, "action": action, "text": "", "suggestions": suggestions,
"model": self.model or "", "offline": self._offline_hint()}
prompt_text = self._build_prompt(
action, prompt=prompt, context=context,
target_language=target_language, prefix=prefix, title=title)
# No connected provider → deterministic, dependency-free output (the raw
# offline planner echoes the prompt, which is wrong for continue/autocomplete).
if self._offline_hint():
return {"ok": True, "action": action, "model": "",
"offline": True,
"text": self._offline_text(action, prompt=prompt, context=context,
target_language=target_language,
prefix=prefix, title=title)}
try:
text, model, offline = await self._complete(prompt_text, context=context)
except Exception as exc: # noqa: BLE001 — surface provider errors to the UI
logger.warning("AI writing '%s' failed: %s", action, exc)
return {"ok": False, "action": action, "error": str(exc),
"text": "", "model": self.model or "", "offline": False}
if not text:
text = self._offline_text(action, prompt=prompt, context=context,
target_language=target_language,
prefix=prefix, title=title)
offline = True
return {"ok": True, "action": action, "text": text,
"model": model, "offline": offline}
# ── Prompt building ──
def _build_prompt(self, action: str, *, prompt: str, context: str,
target_language: str, prefix: str, title: str) -> str:
if action == "write":
subject = (prompt or title or "ce document").strip()
return (f"Rédige le contenu demandé : {subject}. "
"Fournis un texte structuré et directement utilisable.")
if action == "summarize":
return ("Résume le contenu fourni de façon structurée et concise : "
"un court paragraphe d'introduction puis 3 à 5 points clés à puces.")
if action == "translate":
lang = (target_language or "English").strip()
return (f"Traduis l'intégralité du contenu fourni en {lang}, "
"en conservant fidèlement sa structure (titres, listes, paragraphes). "
"Ne traduis pas les noms propres et les termes techniques.")
if action == "continue":
return ("Poursuis naturellement le texte fourni. "
"Écris un à trois paragraphes cohérents avec le style et le sujet, "
"sans répéter ce qui précède et sans introduction.")
if action == "autocomplete":
return (f"Complète la phrase en cours par une suite courte et pertinente "
f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets "
f"aucun préambule. Texte en cours : {prefix!r}")
return prompt
# ── Offline deterministic fallbacks ──
def _offline_hint(self) -> bool:
try:
llm = self._client()
return llm.provider == "offline" or not llm._has_credentials()
except Exception: # noqa: BLE001
return True
def _offline_text(self, action: str, *, prompt: str, context: str,
target_language: str, prefix: str, title: str) -> str:
if action == "summarize":
return self._offline_summary(context)
if action == "translate":
return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n"
f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire "
f"ce document en {target_language or 'English'}.")
if action == "autocomplete":
return self._offline_autocomplete(prefix)
if action == "continue":
return ("Suite du contenu : développez ici le point précédent avec un exemple "
"concret, puis ouvrez la prochaine idée en une phrase de transition.")
subject = (prompt or title or "ce document").strip()
return (f"## {subject}\n"
"\n"
"Présentation générale du sujet : objectif, contexte et public visé en "
"quelques phrases. (Contenu généré hors-ligne — connectez une clé API "
"pour une rédaction complète.)\n"
"\n"
"## Points clés\n"
"• Idée principale 1 et son argument.\n"
"• Idée principale 2 avec un exemple concret.\n"
"\n"
"## Prochaines étapes\n"
"• Relire, compléter et mettre en forme ce contenu.")
@staticmethod
def _offline_summary(context: str) -> str:
text = (context or "").strip()
if not text:
return "Résumé : aucun contenu fourni à résumer."
headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE)
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text))
lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip())
out = ["**Résumé**", "", lead[:400], ""]
bullets = []
if headings:
bullets = [f"• {h.strip()}" for h in headings[:5]]
else:
for s in sentences[1:6]:
s = s.strip()
if len(s) > 30:
bullets.append(f"• {s[:180]}")
if bullets:
out.append("**Points clés**")
out.extend(bullets)
return "\n".join(out)
@staticmethod
def _offline_autocomplete(prefix: str) -> str:
prefix = (prefix or "").strip()
if len(prefix) < 8:
return ""
return " Cette section détaille les points clés à retenir."
# ── AI properties ──
async def suggest_properties(self, *, context: str = "", title: str = "",
properties: list | None = None) -> dict:
"""Return ``{property_name: value}`` suggestions for a collection page.
``properties`` is a list of ``{name, type}`` dicts. With a connected
provider the model is asked for a JSON object; offline we derive
deterministic defaults from the property names so the UI stays useful.
"""
properties = properties or []
if not properties:
return {}
names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)]
names = [n for n in names if n]
llm = self._client()
offline = llm.provider == "offline" or not llm._has_credentials()
if not offline:
schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)}
prompt = (
"À partir du titre et du contenu du document, propose une valeur pour "
"chaque propriété. Réponds STRICTEMENT par un objet JSON "
"{\"nom_propriété\": valeur} sans texte autour.\n"
f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n"
f"Titre : {title or '(sans titre)'}"
)
try:
text, _, _ = await self._complete(prompt, context=context)
parsed = self._parse_json_object(text)
if parsed:
return self._coerce_suggestions(parsed, properties)
except Exception as exc: # noqa: BLE001
logger.warning("AI properties failed: %s", exc)
# fall through to deterministic defaults
return self._offline_suggestions(title, context, properties)
@staticmethod
def _parse_json_object(text: str) -> dict:
text = (text or "").strip()
if not text:
return {}
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not m:
return {}
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _coerce_suggestions(self, parsed: dict, properties: list) -> dict:
out: dict = {}
by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)}
for key, value in parsed.items():
prop = by_name.get(str(key).strip().lower())
if not prop:
continue
out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text"))
return out
@staticmethod
def _coerce_value(value, prop_type: str):
ptype = (prop_type or "text").lower()
if ptype == "checkbox":
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x")
if ptype == "number":
try:
num = float(value)
return int(num) if num.is_integer() else num
except (TypeError, ValueError):
return value
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
return value
@staticmethod
def _offline_suggestions(title: str, context: str, properties: list) -> dict:
out: dict = {}
summary_text = ""
if context:
summary_text = re.sub(r"\s+", " ", context).strip()
first = re.split(r"(?<=[.!?])\s+", summary_text)
summary_text = next((s for s in first if len(s) > 40), summary_text)[:180]
for prop in properties:
if not isinstance(prop, dict):
continue
name = str(prop.get("name", "")).strip()
if not name:
continue
low = name.lower()
ptype = (prop.get("type") or "text").lower()
matched = False
for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS:
if any(k in low for k in keys):
if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys:
out[name] = summary_text or (title or "")
else:
out[name] = default
matched = True
break
if not matched and ptype in ("text", "title"):
if "name" in low or "titre" in low or "title" in low:
out[name] = title or ""
return out
async def run_action(action: str, **kwargs) -> dict:
"""Module-level convenience wrapper (used by tests and simple callers)."""
return await AIWritingService().run(action, **kwargs)
-400
View File
@@ -1,400 +0,0 @@
"""FlowDeck — Automations engine (v5.1.0).
Implements the "if-this-then-that" rule engine: automations match an event (or a
cron schedule, or a clickable button), optionally guard on a condition, then run
a list of actions.
Condition clauses (``condition_json``), all combined with AND:
{"property": "Status", "op": "eq", "value": "Done"}
{"property": "Priority", "op": "not_contains", "value": "Low"}
{"property": "Assignee", "op": "is_empty"}
{"property": "Estimate", "op": "changed"} (only event triggers)
Flags:
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
Actions (``actions_json``), executed sequentially:
{"type": "webhook", "url": "...", "secret": "..."}
{"type": "set_property", "property": "Status", "value": "Done"}
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
{"type": "notify", "message": "Automation fired"}
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
import httpx
from app.db import get_conn
from app.services import notifications
logger = logging.getLogger(__name__)
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
# Events that fire on collection pages (payload carries a `properties` dict).
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
def _prop_value(props: dict, key) -> tuple[bool, object]:
"""Resolve a property value by id or name. Returns ``(found, value)``.
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
"""
if props is None:
return False, None
if key is None:
return True, None
skey = str(key)
if skey in props:
return True, props[skey]
if isinstance(key, int) and str(key) in props:
return True, props[str(key)]
return False, None
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
"""Evaluate a single condition clause against page property values."""
op = clause.get("op", "eq")
if op not in COND_OPS:
return False
if op == "changed":
key = clause.get("property")
if before_props is None:
return False
found_before, before_val = _prop_value(before_props, key)
found_after, after_val = _prop_value(props, key)
return found_before and found_after and before_val != after_val
found, val = _prop_value(props, clause.get("property"))
if op == "is_empty":
if not found:
return True
return val is None or str(val).strip() == ""
if op == "is_not_empty":
return found and val is not None and str(val).strip() != ""
if not found:
return False
want = clause.get("value")
if op == "eq":
return _norm(val) == _norm(want)
if op == "neq":
return _norm(val) != _norm(want)
if op == "contains":
return _norm(want) in _norm(val) if _norm(val) else False
if op == "not_contains":
return _norm(want) not in _norm(val) if _norm(val) else True
return False
def _norm(v) -> str:
if v is None:
return ""
if isinstance(v, (list, dict)):
return json.dumps(v)
return str(v)
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
try:
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
except (TypeError, json.JSONDecodeError):
clauses = []
for clause in clauses or []:
if not match_condition_props(props, before_props, clause):
return False
return True
def get_page_context(page_id: int, collection_id: int) -> dict:
"""Load a collection page's property values for condition evaluation."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
return {"page_id": page_id, "collection_id": collection_id,
"title": "", "properties": {}, "icon": "file"}
try:
props = json.loads(row["property_values_json"])
except (TypeError, json.JSONDecodeError):
props = {}
return {"page_id": page_id, "collection_id": collection_id,
"title": row["title"], "icon": row["icon"], "properties": props}
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
collection_id: int | None = None, page_id: int | None = None) -> None:
with get_conn() as conn:
conn.execute(
"""INSERT INTO automation_runs
(automation_id, trigger_source, status, detail, collection_id, page_id)
VALUES (?,?,?,?,?,?)""",
(automation_id, trigger_source, status, detail, collection_id, page_id),
)
conn.execute(
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
(automation_id,),
)
conn.commit()
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
if not isinstance(value, str):
return True, value
replaced = value
for key in props:
if "[[" + str(key) + "]]" in replaced:
replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
if "{{title}}" in replaced:
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
if "{{id}}" in replaced:
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
return True, replaced
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
"""Execute a single action. Returns a human summary. Raises on failure."""
atype = action.get("type")
if atype == "webhook":
url = action.get("url", "").strip()
if not url:
raise ValueError("webhook action requires a url")
secret = action.get("secret", "")
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret:
headers["X-FlowDeck-Secret"] = secret
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=context, headers=headers)
if resp.status_code >= 400:
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
return f"webhook → {url} ({resp.status_code})"
if atype == "set_property":
prop = action.get("property")
value = action.get("value")
page_id = context.get("page_id")
if not prop or not page_id:
raise ValueError("set_property requires property + page context")
_, resolved = _maybe_convert_prediction(value, context)
with get_conn() as conn:
row = conn.execute(
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
raise ValueError(f"page {page_id} not found")
try:
props = json.loads(row["property_values_json"])
except (TypeError, json.JSONDecodeError):
props = {}
props[prop] = resolved
from app.routers.collections import _validate_page_properties
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return f"set property {prop} = {resolved}"
if atype == "create_page":
coll_id = action.get("collection_id")
title = action.get("title", "Automation page")
properties = action.get("properties", {}) or {}
if not coll_id:
raise ValueError("create_page requires a collection_id")
_, resolved_title = _maybe_convert_prediction(title, context)
resolved_props = {}
for k, v in properties.items():
_, pv = _maybe_convert_prediction(v, context)
resolved_props[k] = pv
with get_conn() as conn:
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(coll_id,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
)
conn.commit()
return f"created page {cur.lastrowid} in collection {coll_id}"
if atype == "notify":
message = action.get("message", "Automation fired")
user_id = action.get("user_id")
if not user_id:
user_id = context.get("created_by") or 1
_, resolved = _maybe_convert_prediction(message, context)
notifications.create_notification(
user_id=user_id,
actor_id=context.get("created_by") or 1,
ntype="page",
title=context.get("automation_name", "Automation"),
message=resolved,
resource_type="collection_page" if context.get("page_id") else "page",
resource_id=context.get("page_id") or context.get("collection_id") or 0,
url=context.get("url", ""),
)
return f"notified user {user_id}"
raise ValueError(f"unknown action type: {atype!r}")
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
"""Load, condition-check and execute an automation. Records a run row."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
if not row:
return {"status": "skipped", "detail": "automation not found"}
auto = dict(row)
if not auto["enabled"]:
return {"status": "skipped", "detail": "automation disabled"}
props = context.get("properties")
before = context.get("before_properties")
if not evaluate_conditions(auto["condition_json"], props, before):
_save_run(automation_id, trigger_source, "skipped", "condition not met",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "condition not met"}
try:
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
except (TypeError, json.JSONDecodeError):
actions = []
ctx = dict(context)
ctx["automation_name"] = auto["name"]
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
results = []
try:
for action in actions or []:
results.append(await _run_action(action, ctx, trigger_source))
detail = "; ".join(results)
_save_run(automation_id, trigger_source, "fired", detail,
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "fired", "detail": detail}
except Exception as exc: # noqa: BLE001 — record every failure in history
logger.warning("Automation %s failed: %s", automation_id, exc)
_save_run(automation_id, trigger_source, "error", str(exc),
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "error", "detail": str(exc)}
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
try:
from app.services.webhook_outbound import fire_event as fire_webhooks
await fire_webhooks(event, payload)
except Exception: # noqa: BLE001
logger.debug("Webhook dispatch failed for %s", event)
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automations
WHERE trigger_type='event' AND event=? AND enabled=1""",
(event,),
).fetchall()
for row in rows:
auto = dict(row)
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
continue
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
_SUPPORTED_CRON = {
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
}
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
"""True when a ``*/N`-style or fixed-minute cron expression is due.
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
"""
expr = (expression or "").strip().lower()
if not expr:
return False
now = now or datetime.utcnow()
minute = now.minute
fields = expr.split()
if expr in ("@hourly", "hourly"):
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
if expr in ("@daily", "daily"):
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
# "*/N * * * *" → every N minutes
if fields and fields[0].startswith("*/"):
val = fields[0][2:]
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
return False
n = int(val)
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
# "H * * * *" → at a fixed minute of each hour
if len(fields) == 5 and fields[0].isdigit():
return int(fields[0]) == minute
return False
async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
).fetchall()
for row in rows:
auto = dict(row)
try:
if cron_due(auto["cron_expression"], auto["last_run_at"]):
context = {
"collection_id": auto["collection_id"] or 0,
"page_id": None,
"properties": None,
}
await run_automation(auto["id"], "cron", context)
except Exception: # noqa: BLE001
logger.warning("Cron automation %s errored", auto["id"])
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
-111
View File
@@ -1,111 +0,0 @@
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
from __future__ import annotations
import logging
import shutil
import time
from datetime import datetime
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
def _backup_dir() -> Path:
d = Path(settings.backup_dir)
d.mkdir(parents=True, exist_ok=True)
return d
def _db_path() -> Path:
return settings.db_path
def backup_db(now: datetime | None = None) -> str | None:
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
Returns the backup filename, or None when backup is disabled or the
database file does not exist.
"""
if not settings.backup_enabled:
return None
db_path = _db_path()
if str(db_path) == ":memory:" or not Path(db_path).is_file():
return None
now = now or datetime.now()
# Checkpoint the WAL so the backup is consistent.
try:
import sqlite3
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
dest = dest_dir / filename
shutil.copy2(db_path, dest)
# Prune old backups, keeping ``backup_keep`` most recent files.
prune_old_backups()
logger.info("Backup created: %s", dest)
return filename
def list_backups() -> list[dict]:
"""List existing backup files (name, size bytes, mtime)."""
files = []
for p in _backup_dir().glob("flowdeck-*.db"):
stat = p.stat()
files.append({
"filename": p.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
files.sort(key=lambda f: f["filename"], reverse=True)
return files
def prune_old_backups(keep: int | None = None) -> int:
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
keep = keep if keep is not None else settings.backup_keep
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
removed = 0
for p in files[keep:]:
try:
p.unlink()
removed += 1
except OSError:
logger.warning("Could not prune backup %s", p)
return removed
def last_backup_age_hours() -> float | None:
"""Hours since the most recent backup (None if none exists)."""
files = list(_backup_dir().glob("flowdeck-*.db"))
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
age = time.time() - newest.stat().st_mtime
return age / 3600
def backup_due() -> bool:
"""True when a backup should run now (interval elapsed since last one)."""
age = last_backup_age_hours()
if age is None:
return True
return age >= settings.backup_interval_hours
async def backup_scheduler():
"""Background loop: run a backup once per interval (default daily)."""
while True:
try:
if backup_due():
backup_db()
except Exception as exc: # never let the loop die
logger.warning("backup_scheduler error: %s", exc)
await __import__("asyncio").sleep(3600) # re-check hourly
-103
View File
@@ -1,103 +0,0 @@
"""FlowDeck — Built-in page (block) templates (v5.12.0).
Global page templates used by the « + New page » picker. Built-ins live here
(code, versioned); user templates live in the ``page_global_templates``
table. Block shapes match the editor's storage format (see
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
"""
from __future__ import annotations
import json
def _b(btype: str, content: str = "", **extra) -> dict:
out = {"type": btype, "content": content}
out.update(extra)
return out
BUILTIN_TEMPLATES: dict[str, dict] = {
"empty": {
"name": "Empty",
"icon": "📄",
"description": "A blank page.",
"blocks": [_b("paragraph")],
},
"meeting_notes": {
"name": "Meeting notes",
"icon": "🗒️",
"description": "Attendees, agenda, notes, action items.",
"blocks": [
_b("heading_1", "Meeting notes"),
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
_b("heading_2", "Agenda"),
_b("bulleted_list", "Topic 1"),
_b("bulleted_list", "Topic 2"),
_b("heading_2", "Notes"),
_b("paragraph"),
_b("heading_2", "Decisions"),
_b("bulleted_list"),
_b("heading_2", "Action items"),
_b("to_do", "Owner — due date", checked=False),
_b("to_do", "", checked=False),
],
},
"weekly_report": {
"name": "Weekly report",
"icon": "📊",
"description": "Wins, in progress, blockers, next week.",
"blocks": [
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
_b("heading_2", "🎉 Wins"),
_b("bulleted_list"),
_b("heading_2", "🚧 In progress"),
_b("bulleted_list"),
_b("heading_2", "⛔ Blockers"),
_b("bulleted_list"),
_b("heading_2", "🗓️ Next week"),
_b("to_do", "", checked=False),
],
},
"todo_list": {
"name": "To-do list",
"icon": "✅",
"description": "A simple checklist.",
"blocks": [
_b("heading_1", "To-do"),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
],
},
"project_doc": {
"name": "Project doc",
"icon": "🚀",
"description": "Goals, status, team, links.",
"blocks": [
_b("heading_1", "Project title"),
_b("callout", "One-line description of the project.", icon="💡"),
_b("heading_2", "Goals"),
_b("numbered_list"),
_b("heading_2", "Status"),
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
_b("heading_2", "Team"),
_b("bulleted_list"),
_b("heading_2", "Resources"),
_b("bulleted_list"),
],
},
}
def template_list() -> list[dict]:
"""Public shape of the built-in templates for the picker UI."""
return [
{"key": key, "name": t["name"], "icon": t["icon"],
"description": t["description"], "builtin": True}
for key, t in BUILTIN_TEMPLATES.items()
]
def blocks_json_for(key: str) -> str | None:
t = BUILTIN_TEMPLATES.get(key)
return json.dumps(t["blocks"]) if t else None
+5 -4
View File
@@ -6,6 +6,7 @@ without breaking the existing board routes.
from __future__ import annotations
import json
from typing import Optional
from app.db import get_conn
@@ -48,7 +49,7 @@ class GiteaBoardCompat:
}
@staticmethod
def from_card(card_row, gitea_issue: dict | None = None) -> dict:
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
"""Convertit une card legacy en pseudo collection_page."""
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
priority = card_row.get("priority", "Medium")
@@ -82,7 +83,7 @@ class GiteaBoardCompat:
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
@staticmethod
def get_board_as_collection(owner: str, repo: str) -> dict | None:
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
"""Récupère un board spécifique comme collection."""
with get_conn() as conn:
row = conn.execute(
@@ -94,7 +95,7 @@ class GiteaBoardCompat:
return GiteaBoardCompat.from_board(dict(row))
@staticmethod
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
"""Récupère les cartes d'un board comme collection_pages."""
with get_conn() as conn:
board = conn.execute(
@@ -119,7 +120,7 @@ class GiteaBoardCompat:
]
@staticmethod
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
"""Sync un board Gitea vers une vraie collection.
Crée ou met à jour une collection liée à Gitea et importe les pages.
-230
View File
@@ -1,230 +0,0 @@
"""FlowDeck — Agent context builder (v4.14.0).
Collects a compact, permission-filtered snapshot of the active workspace so the
LLM can reason about real entities (workspaces, documents, collections, pages,
Gitea issues) without touching the database directly.
"""
from __future__ import annotations
import json
from app.db import get_conn
class ContextBuilder:
"""Builds the textual context that accompanies each agent run."""
def __init__(self, user_id: int, workspace_id: int | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
def build(self, *, mentions: list[str] | None = None,
files: list[dict] | None = None,
include_collections: bool = True) -> str:
"""Return a compact Markdown-ish snapshot of the workspace context."""
sections: list[str] = []
if include_collections:
sections.append(self._collections_context())
sections.append(self._pages_context())
sections.append(self._documents_context())
sections.append(self._workspaces_context())
if mentions:
sections.append(self._mentions_context(mentions))
if files:
sections.append(self._files_context(files))
return "\n\n".join(s for s in sections if s)
# ── Internals ──
def _collections_context(self) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
).fetchall()
if not rows:
return "## Collections\n(no collections yet)"
lines = ["## Collections"]
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
for r in rows:
props = json.loads(r["schema_json"]) if r["schema_json"] else []
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
lock = " [LOCKED]" if r["is_locked"] else ""
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
return "\n".join(lines)
def _pages_context(self, limit: int = 40) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return "## Pages\n(no pages yet)"
lines = ["## Recent pages"]
for r in rows:
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
return "\n".join(lines)
def _documents_context(self, limit: int = 30) -> str:
"""Recent editor documents (`pages`), usable with create/read/update tools."""
with get_conn() as conn:
ws_names = dict(
conn.execute("SELECT id, name FROM workspaces").fetchall()
)
rows = conn.execute(
"SELECT id, title, workspace_id, content_format, parent_id "
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return "## Documents\n(aucun document)"
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
for r in rows:
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
return "\n".join(lines)
def _workspaces_context(self) -> str:
"""Workspaces accessible to the current user (with counts)."""
base_sql = (
"SELECT w.id, w.name, {role} AS role, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count "
"FROM workspaces w {join} {where} ORDER BY w.name"
)
with get_conn() as conn:
if self.user_id is None:
rows = conn.execute(
base_sql.format(role="'owner'", join="", where=""), []
).fetchall()
else:
rows = conn.execute(
base_sql.format(
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
),
(self.user_id, self.user_id, self.user_id),
).fetchall()
if not rows:
return "## Espaces de travail\n(aucun espace)"
lines = ["## Espaces de travail (outil: read_workspaces)"]
for r in rows:
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without
needing a read tool round-trip (and so the offline mock stays useful).
"""
lines = ["## Mentioned context"]
for m in mentions:
if m.startswith("document:"):
pid = m.split(":", 1)[1]
lines.append(self._single_document(pid))
elif m.startswith("collection:"):
cid = m.split(":", 1)[1]
lines.append(self._single_collection(cid))
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
lines.append("- @ws: full workspace context included above")
return "\n".join(lines)
@staticmethod
def _blocks_to_text(content: str, limit: int = 9000) -> str:
"""Flatten a ``blocks`` document JSON into plain readable text.
Collects the textual payload of each block (content, title, caption,
children, meeting notes/summary) — enough for the LLM to reason about a
mentioned editor page without the full block schema.
"""
try:
blocks = json.loads(content or "[]")
except (json.JSONDecodeError, TypeError):
return ""
if not isinstance(blocks, list):
return ""
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
out: list[str] = []
total = 0
def walk(node):
nonlocal total
if total >= limit:
return
if isinstance(node, dict):
for k in _TEXT_KEYS:
v = node.get(k)
if isinstance(v, str) and v.strip():
line = v.replace("\r\n", "\n").strip()
out.append(line)
total += len(line) + 1
if total >= limit:
return
for v in node.values():
walk(v)
elif isinstance(node, list):
for item in node:
walk(item)
walk(blocks)
return "\n".join(out)[:limit]
def _single_document(self, pid: str) -> str:
"""Full editor-document mention: title + workspace + real content."""
with get_conn() as conn:
row = conn.execute(
"SELECT p.*, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.id=? AND p.deleted_at IS NULL",
(pid,),
).fetchone()
if not row:
return f"- document #{pid}: not found"
title = row["title"] or "Sans titre"
ws = row["ws_name"] or ""
loc = f" (espace: {ws})" if ws else ""
fmt = row["content_format"] or "blocks"
raw = row["content"] or ""
if fmt == "markdown":
body = raw.strip()
elif fmt == "file":
body = ""
else:
body = self._blocks_to_text(raw)
head = f"- document #{row['id']} **{title}**{loc}"
if body:
return f"{head}:\n{body[:9000]}"
return head
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
return f"- collection #{cid}: not found"
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
def _single_page(self, pid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not row:
return f"- page #{pid}: not found"
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
def _files_context(self, files: list[dict]) -> str:
return "## Attached files\n" + "\n".join(
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
)
-203
View File
@@ -1,203 +0,0 @@
"""FlowDeck — Database templates (v5.3.0).
Defines the built-in (seeded) database templates, materializes a template's
schema into real ``collection_properties`` rows, and creates a collection from
a template. Templates are stored in ``database_templates`` (name, icon,
description, schema_json).
"""
from __future__ import annotations
import json
# ── Built-in templates ──
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
SEED_TEMPLATES: list[dict] = [
{
"name": "Project tracker",
"icon": "🚀",
"description": "Suivi de projets avec statut, priorité et échéances.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "Not started", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "orange"},
{"name": "Urgent", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "CRM / Contacts",
"icon": "👥",
"description": "Gestion des contacts et prospects.",
"schema": [
{"name": "Name", "type": "title"},
{"name": "Email", "type": "email"},
{"name": "Phone", "type": "phone"},
{"name": "Company", "type": "text"},
{"name": "Stage", "type": "status", "options": [
{"name": "Lead", "color": "gray"},
{"name": "Prospect", "color": "blue"},
{"name": "Customer", "color": "green"},
]},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "Task list",
"icon": "✅",
"description": "Liste de tâches simple avec assignation et échéance.",
"schema": [
{"name": "Task", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "To do", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
],
},
{
"name": "Content calendar",
"icon": "📅",
"description": "Planification de contenu et de publications.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Type", "type": "select", "options": [
{"name": "Article", "color": "blue"},
{"name": "Video", "color": "orange"},
{"name": "Social", "color": "green"},
{"name": "Newsletter", "color": "purple"},
]},
{"name": "Status", "type": "status", "options": [
{"name": "Draft", "color": "gray"},
{"name": "In review", "color": "yellow"},
{"name": "Published", "color": "green"},
]},
{"name": "Publish date", "type": "date"},
{"name": "Category", "type": "select"},
],
},
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants, agenda, notes et actions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
{"name": "Attendees", "type": "person"},
{"name": "Status", "type": "status", "options": [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
{"name": "Action items", "type": "multi_select"},
],
},
{
"name": "Reading list",
"icon": "📚",
"description": "Articles, livres et ressources à lire.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "URL", "type": "url"},
{"name": "Status", "type": "status", "options": [
{"name": "To read", "color": "gray"},
{"name": "Reading", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Notes", "type": "text"},
],
},
]
def materialize_properties(conn, collection_id: int, schema: list) -> None:
"""Insert ``collection_properties`` rows from a template ``schema``.
The ``title`` property is represented by ``collection_pages.title`` and is
not created as a column. Rows are inserted in schema order.
"""
position = 0
for prop in schema:
name = (prop.get("name") or "").strip()
if not name:
continue
prop_type = prop.get("type", "text")
if prop_type == "title":
continue
options = prop.get("options") or []
# idempotency guard — skip if a same-named property already exists
exists = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, name),
).fetchone()
if exists:
continue
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format, position)
VALUES (?, ?, ?, ?, 'number', ?)""",
(collection_id, name, prop_type, json.dumps(options), position),
)
position += 1
def create_from_template(
conn,
name: str,
template: dict,
*,
parent_page_id: int | None = None,
workspace_id: int | None = None,
) -> int:
"""Create a collection from a template (with properties + default view).
``template`` may be a DB row (sqlite Row) or a dict; it must expose
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
"""
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
description = template.get("description") if isinstance(template, dict) else template["description"]
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
try:
schema = json.loads(schema_json)
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description or "", icon or "📋", json.dumps(schema),
parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
return collection_id
-278
View File
@@ -1,278 +0,0 @@
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
embed.
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
fall back to a plain iframe so the link is still visible inline.
"""
from __future__ import annotations
import re
from urllib.parse import parse_qs, quote, urlparse
def _q(params, key):
vals = params.get(key)
return vals[0] if vals else ""
def _host_matches(netloc: str, host: str) -> bool:
"""True when ``netloc`` is ``host`` or one of its subdomains."""
netloc = (netloc or "").lower().split(":")[0]
host = host.lower()
return netloc == host or netloc.endswith("." + host)
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
vid = m.group(1) if m else _q(params, "v")
if not vid:
# youtu.be/<id> (short link) — the id is the first path segment.
seg = path.strip("/").split("/")[0]
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
vid = seg
if not vid:
return None
start = _q(params, "t") or _q(params, "start")
frag = f"?start={start}" if start else ""
return f"https://www.youtube.com/embed/{vid}{frag}"
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(\d{6,12})", path)
if not m:
return None
return f"https://player.vimeo.com/video/{m.group(1)}"
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
if not m:
return None
return f"https://www.loom.com/embed/{m.group(1)}"
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
clean = url.split("?", 1)[0]
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
return None
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
return None
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
)
if not m:
return None
kind, doc_id = m.group(1), m.group(2)
if kind == "forms":
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
if not m:
return None
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
if not m:
return None
return f"https://miro.com/app/live-embed/{m.group(1)}"
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
if not m:
return None
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
if "soundcloud.com" not in url:
return None
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
if "twitch.tv" not in url:
return None
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
video = re.search(r"twitch\.tv/videos/(\d+)", url)
if video:
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
m = re.search(r"twitch\.tv/([^/?#]+)", url)
if not m or m.group(1) in ("videos", "directory"):
return None
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
if "twitter.com" not in url and "x.com" not in url:
return None
m = re.search(r"/status(?:es)?/(\d+)", url)
if not m:
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
if "pinterest" not in url:
return None
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
low = url.lower().split("?", 1)[0]
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
return None
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
return url
# (host, handler) — order matters: more specific hosts first.
_HANDLERS = (
("youtube.com", _embed_youtube),
("youtu.be", _embed_youtube),
("vimeo.com", _embed_vimeo),
("loom.com", _embed_loom),
("figma.com", _embed_figma),
("docs.google.com", _embed_gdocs),
("google.com/maps", _embed_map),
("maps.app.goo.gl", _embed_map),
("codepen.io", _embed_codepen),
("miro.com", _embed_miro),
("open.spotify.com", _embed_spotify),
("spotify.com", _embed_spotify),
("soundcloud.com", _embed_soundcloud),
("twitch.tv", _embed_twitch),
("twitter.com", _embed_twitter),
("x.com", _embed_twitter),
("pinterest.", _embed_pinterest),
("office.com", _embed_office),
("officeapps.live.com", _embed_office),
("sharepoint.com", _embed_office),
("1drv.ms", _embed_office),
)
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
def _parse(url: str):
raw = url.strip()
if not raw:
return None, None, None
m = _SCHEME_RE.match(raw)
if m:
if m.group(1).lower() not in ("http", "https"):
return None, None, None # mailto:, tel:, javascript:, data:…
else:
raw = "https://" + raw
u = urlparse(raw)
if u.scheme not in ("http", "https") or not u.netloc:
return None, None, None
host = u.hostname or ""
if "." not in host and host != "localhost":
return None, None, None # a bare word is not a URL
return raw, u, parse_qs(u.query)
def embed_src(url: str, *, parent: str = "") -> str | None:
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
raw, u, params = _parse(url)
if raw is None:
return None
ctx = {"parent": parent}
netloc = (u.netloc or "").lower()
for needle, handler in _HANDLERS:
if "/" in needle or needle.endswith("."):
if needle in raw.lower():
return handler(raw, u.path, params, ctx)
elif _host_matches(netloc, needle):
return handler(raw, u.path, params, ctx)
# Office documents hosted on arbitrary domains.
office = _embed_office(raw, u.path, params, ctx)
if office:
return office
return _embed_files(raw, u.path, params, ctx)
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
_PDF_EXT = re.compile(r"\.pdf$", re.I)
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
_AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
def inline_kind(url: str) -> str | None:
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
| 'audio'. Returns None when the URL should open in a new tab."""
raw, u, _params = _parse(url)
if raw is None:
return None
path = u.path or ""
if _IMAGE_EXT.search(path):
return "image"
if _PDF_EXT.search(path):
return "pdf"
if _VIDEO_EXT.search(path):
return "video"
if _AUDIO_EXT.search(path):
return "audio"
return "iframe"
def provider(url: str) -> str:
"""Human-readable provider name for a URL (used by the editor)."""
raw, u, _params = _parse(url)
if raw is None:
return ""
netloc = (u.netloc or "").lower()
for needle, _handler in _HANDLERS:
if "/" in needle or needle.endswith("."):
if needle in raw.lower():
return needle.split(".")[0].rstrip(".")
elif _host_matches(netloc, needle):
name = needle.split(".")[0]
return "youtube" if name == "youtu" else name
return ""
def resolve_embed(url: str, *, parent: str = "") -> dict:
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
kind = inline_kind(url)
return {
"src": embed_src(url, parent=parent) or "",
"kind": kind or "",
"provider": provider(url),
}
def embed_html(src: str, *, height: int = 520) -> str:
"""A responsive, borderless iframe for a provider embed URL."""
return (
f'<iframe src="{src}" loading="lazy" '
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
f'picture-in-picture" allowfullscreen></iframe>'
)
-888
View File
@@ -1,888 +0,0 @@
"""FlowDeck — Export service (v4.7.2).
Four types of export, all generated server-side:
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
- HTML (``page_to_standalone_html``): document autonome (styles inline)
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
- Site (``build_static_site``): site statique multi-pages (zip)
Supports the three ways a page's content can be stored:
- content_format == "blocks" -> JSON list of blocks in ``content``
- content_format == "markdown" -> raw Markdown in ``content``
- content_format == "file" -> ``content`` is JSON metadata; the real text
lives in an uploaded file on disk (uploads/workspace_*). We read it back so
an exported document carries its actual content, not just its title.
"""
from __future__ import annotations
import io
import json
import os
import re
import zipfile
from pathlib import Path
from urllib.parse import quote
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
def _text(v: str, *, escape: bool = True) -> str:
"""Normalize a block's content string."""
s = (v or "").replace("\r\n", "\n").replace("\r", "\n")
if escape:
s = (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
return s
def _sanitize_id(block_id) -> str:
if not block_id:
return ""
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _page_title(page: dict) -> str:
return (page.get("title") or "Untitled").strip() or "Untitled"
def _blocks_of(page: dict) -> list:
content = page.get("content") or ""
fmt = page.get("content_format") or "blocks"
if fmt != "blocks" or not content:
return []
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
return []
return data if isinstance(data, list) else []
def _block_text(b: dict) -> str:
return _text(b.get("content"), escape=False)
# ── Source resolution: read real textual content for ANY page type ──
# Extensions whose content is plain text / code / markdown (textual exportable).
_TEXTUAL_EXTS = {
"md", "markdown", "txt", "log", "text",
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
"properties", "gitignore", "dockerfile", "makefile",
}
_CODE_LANG = {
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
"astro": "html", "properties": "ini", "md": "markdown",
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
"text": "plaintext",
}
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
def _data_root() -> Path:
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _file_meta(page: dict) -> dict:
try:
meta = json.loads(page.get("content") or "{}")
return meta if isinstance(meta, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
def _file_text(page: dict) -> str | None:
"""Return the textual content of an uploaded ``file`` page, or None.
Only reads plain-text / code / markdown files. Binary (PDF, images…)
returns None and is skipped by exporters (nothing meaningful to include).
"""
if (page.get("content_format") or "") != "file":
return None
meta = _file_meta(page)
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"):
return None
name = (rel.rsplit("/", 1)[-1] or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
return None
try:
full = (_data_root() / rel).resolve()
root = _data_root().resolve()
if root not in full.parents:
return None
return full.read_text(encoding="utf-8", errors="replace")
except (OSError, ValueError):
return None
def _page_source(page: dict):
"""Return (kind, payload) describing where the page's real content lives.
kind ∈ {"blocks", "md", "code"}:
- "blocks": payload is the block list (block editor pages)
- "md" : payload is raw Markdown text
- "code" : payload is (text, language)
An empty/unsupported page yields ("blocks", []).
"""
fmt = (page.get("content_format") or "blocks")
content = page.get("content") or ""
if fmt == "blocks":
return "blocks", _blocks_of(page)
if fmt == "markdown":
if content.strip():
return "md", content
return "blocks", []
if fmt == "file":
text = _file_text(page)
if text is None:
return "blocks", []
meta = _file_meta(page)
name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
return "md", text
lang = _CODE_LANG.get(ext, "plaintext")
return "code", (text, lang)
# Unknown format (e.g. legacy) -> try to dump as raw text
if content.strip():
return "md", content
return "blocks", []
# ── GFM pipe-table parsing (raw markdown → "table" block) ──
_SEP_CELL = re.compile(r"^:?-+:?$")
def _split_pipe_cells(line: str) -> list[str]:
"""Split a GFM pipe row into trimmed cell strings."""
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith(r"\|"):
s = s[:-1]
# split on unescaped pipes
cells: list[str] = []
cur: list[str] = []
i = 0
while i < len(s):
ch = s[i]
if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|":
cur.append("|")
i += 2
continue
if ch == "|":
cells.append("".join(cur).strip())
cur = []
i += 1
continue
cur.append(ch)
i += 1
cells.append("".join(cur).strip())
return cells
def _is_table_delimiter(line: str) -> bool:
s = line.strip()
if not s:
return False
if s.startswith("|"):
s = s[1:]
if s.endswith("|"):
s = s[:-1]
cells = [c.strip() for c in s.split("|")]
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
def _parse_table_at(lines: list[str], i: int, n: int):
"""If a GFM table starts at index i (header row + delimiter row), return
(table_block, next_index). Otherwise return None."""
header_cells = _split_pipe_cells(lines[i])
if len(header_cells) <= 1:
return None
if i + 1 >= n or not _is_table_delimiter(lines[i + 1]):
return None
sep_cells = _split_pipe_cells(lines[i + 1])
align = []
for c in sep_cells[: len(header_cells)]:
c = c.strip()
if c.startswith(":") and c.endswith(":"):
align.append("center")
elif c.endswith(":"):
align.append("right")
else:
align.append("left")
rows = [header_cells]
j = i + 2
while j < n:
s = lines[j].strip()
if not s or not s.startswith("|"):
break
cells = _split_pipe_cells(lines[j])
rows.append(cells)
j += 1
width = max(len(r) for r in rows)
def pad(r):
return r + [""] * (width - len(r))
align = (align + ["left"] * width)[:width]
return (
{
"type": "table",
"has_header": True,
"align": align,
"rows": [pad(r) for r in rows],
},
j,
)
def _table_to_markdown(b: dict) -> str:
rows = b.get("rows") or []
if not rows:
return ""
align = b.get("align") or []
width = max(len(r) for r in rows)
align = (align + ["left"] * width)[:width]
has_header = b.get("has_header", True)
out: list[str] = []
def rowline(r):
cells = list(r) + [""] * (width - len(r))
return "| " + " | ".join(cells) + " |"
start = 0
if has_header:
out.append(rowline(rows[0]))
seps = []
for a in align:
if a == "center":
seps.append(":---:")
elif a == "right":
seps.append("---:")
else:
seps.append(":---")
out.append("| " + " | ".join(seps) + " |")
start = 1
for ri in range(start, len(rows)):
out.append(rowline(rows[ri]))
return "\n".join(out)
def _table_to_html(b: dict) -> str:
rows = b.get("rows") or []
if not rows:
return ""
align = b.get("align") or []
width = max(len(r) for r in rows)
align = (align + ["left"] * width)[:width]
def cell_html(tag, text, a):
style = f' style="text-align:{a};"' if a and a != "left" else ""
return f"<{tag}{style}>{_text(text)}</{tag}>"
has_header = b.get("has_header", True)
first_col = b.get("first_col_header", False)
header_rows = 1 if has_header else 0
head = ""
if header_rows:
head_rows = []
hr = rows[0]
cells = list(hr) + [""] * (width - len(hr))
head_cells = []
for ci, c in enumerate(cells):
tag = "th" if first_col and ci == 0 else "th"
head_cells.append(cell_html(tag, c, align[ci]))
head_rows.append("<tr>" + "".join(head_cells) + "</tr>")
head = "<thead>" + "".join(head_rows) + "</thead>"
tbody_rows = rows[header_rows:]
body_rows = []
for r in tbody_rows:
cells = list(r) + [""] * (width - len(r))
row_cells = []
for ci, c in enumerate(cells):
tag = "th" if first_col and ci == 0 else "td"
row_cells.append(cell_html(tag, c, align[ci]))
body_rows.append("<tr>" + "".join(row_cells) + "</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>"
return f'<table class="ftable">{head}{body}</table>'
# ── Markdown renderer (raw markdown → exportable fragments) ──
def _md_to_blocks(md: str) -> list:
"""Convert raw Markdown text into the same lightweight block list the
editor produces (headings, lists, to-do, quote, code, divider, paragraph).
Kept intentionally simple: inline formatting (bold/links) is preserved as
literal text, matching how the block editor treats imported .md files.
"""
blocks: list = []
buf = md.replace("\r\n", "\n").replace("\r", "\n")
lines = buf.split("\n")
i = 0
n = len(lines)
para: list[str] = []
def flush_para():
nonlocal para
if para:
blocks.append({"type": "paragraph", "content": "\n".join(para).strip()})
para = []
while i < n:
line = lines[i].rstrip()
stripped = line.strip()
if not stripped:
flush_para()
i += 1
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
flush_para()
fence = stripped[0:3]
lang = stripped[3:].strip()
i += 1
code: list[str] = []
while i < n and not lines[i].strip().startswith(fence):
code.append(lines[i])
i += 1
if i < n:
i += 1 # closing fence
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
continue
if stripped.startswith("|"):
# GFM pipe table: header row immediately followed by a delimiter row
parsed = _parse_table_at(lines, i, n)
if parsed is not None:
flush_para()
tbl, i = parsed
blocks.append(tbl)
continue
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m and line == stripped: # ATX heading must be whole line
level = len(m.group(1))
flush_para()
blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()})
i += 1
continue
if stripped == "---" or stripped == "***" or stripped == "___":
flush_para()
blocks.append({"type": "divider", "content": ""})
i += 1
continue
if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
if not m2:
break
blocks.append({
"type": "to_do",
"content": m2.group(2).strip(),
"checked": m2.group(1).lower() == "x",
})
i += 1
continue
if re.match(r"^\s*[-*+]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^[-*+]\s+(.*)$", s)
if not m2:
break
blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()})
i += 1
continue
if re.match(r"^\s*\d+[.)]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^\d+[.)]\s+(.*)$", s)
if not m2:
break
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
i += 1
continue
mq = re.match(r"^>\s?(.*)$", stripped)
if mq and line == stripped:
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^>\s?(.*)$", s)
if not m2:
break
para.append(m2.group(1))
i += 1
blocks.append({"type": "quote", "content": "\n".join(para)})
para = []
continue
para.append(stripped)
i += 1
flush_para()
return blocks
def _page_blocks(page: dict) -> list:
"""Blocks used for HTML/PDF rendering regardless of storage format."""
kind, payload = _page_source(page)
if kind == "blocks":
return payload
if kind == "code":
text, lang = payload
return [{"type": "code", "content": text, "language": lang}] if text else []
if kind == "md":
return _md_to_blocks(payload)
return []
def _page_markdown_source(page: dict) -> str:
"""Raw markdown when the page IS markdown-sourced, else empty string."""
kind, payload = _page_source(page)
if kind == "md":
return payload
return ""
def markdown_to_blocks(md: str) -> list:
"""Public wrapper around the GFM→blocks parser (used by page import)."""
return _md_to_blocks(md)
# ═══════════════ Markdown ═══════════════
def blocks_to_markdown(blocks: list) -> str:
"""Convert a block array to Markdown (server-side, all block types)."""
out: list[str] = []
for b in blocks or []:
t = b.get("type", "paragraph")
c = _block_text(b)
if t == "heading_1":
out.append(f"# {c}")
elif t == "heading_2":
out.append(f"## {c}")
elif t == "heading_3":
out.append(f"### {c}")
elif t == "heading_4":
out.append(f"#### {c}")
elif t == "bulleted_list":
out.append(f"- {c}")
elif t == "numbered_list":
out.append(f"1. {c}")
elif t == "to_do":
out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}")
elif t == "quote":
out.append(f"> {c}")
elif t == "divider":
out.append("---")
elif t == "code":
lang = b.get("language") or ""
out.append(f"```{lang}\n{c}\n```")
elif t == "toggle":
out.append(f"### {c}")
if b.get("children"):
out.append(blocks_to_markdown(b["children"]))
elif t == "math":
out.append(f"$$\n{c}\n$$")
elif t == "table_of_contents":
out.append("[TOC]")
elif t == "columns":
for child in b.get("children") or []:
out.append(blocks_to_markdown([child]))
elif t == "image":
src = b.get("src") or ""
alt = (b.get("alt") or "").strip() or "image"
out.append(f"![{alt}]({src})")
elif t == "video":
out.append(f"[Video]({b.get('src') or ''})")
elif t == "audio":
out.append(f"[Audio]({b.get('src') or ''})")
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = (b.get("title") or "").strip()
out.append(f"[{title or url}]({url})" if title else url)
elif t == "embed":
url = b.get("src") or ""
if b.get("embed_type") in ("pdf", "download", None, ""):
out.append(f"[{url}]({url})" if url else "[embed]")
else:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table":
out.append(_table_to_markdown(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
out.append(blocks_to_markdown(resolved))
else:
out.append(str(resolved))
except Exception:
out.append(f"[Synced block {synced_id}]")
else:
out.append(c)
return "\n\n".join(filter(None, out))
def _child_pages(page: dict) -> list:
"""Immediate non-deleted children of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"ORDER BY COALESCE(sort_order, created_at) ASC, id ASC",
(page["id"],),
).fetchall()
return [dict(r) for r in rows]
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
"""Markdown for a single page, with optional sub-pages appended."""
parts = [f"# {_page_title(page)}", ""]
md_source = _page_markdown_source(page)
if md_source:
parts.append(md_source.strip())
else:
md = blocks_to_markdown(_page_blocks(page))
if md:
parts.append(md)
md = "\n\n".join(filter(None, parts)).rstrip()
if include_children:
for sub in _child_pages(page):
sub_md = page_to_markdown(sub, include_children=True)
if sub_md:
md += f"\n\n---\n\n{sub_md}"
return md
# ═══════════════ HTML ═══════════════
def blocks_to_html(blocks: list) -> str:
"""Convert a block array to a self-contained HTML fragment."""
parts: list[str] = []
for b in blocks or []:
t = b.get("type", "paragraph")
c = _text(b.get("content"))
if t == "heading_1":
parts.append(f'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
elif t == "heading_2":
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
elif t == "heading_3":
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
elif t == "heading_4":
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
elif t == "bulleted_list":
parts.append(f"<li>{c}</li>")
elif t == "numbered_list":
parts.append(f"<li>{c}</li>")
elif t == "to_do":
checked = "checked" if b.get("checked") else ""
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
parts.append(
f'<div class="todo"><input type="checkbox" {checked} disabled>'
f'<span style="{style}">{c}</span></div>'
)
elif t == "toggle":
children = blocks_to_html(b.get("children") or [])
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
elif t == "quote":
parts.append(f"<blockquote>{c}</blockquote>")
elif t == "divider":
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(f"<pre>{label}<code>{c}</code></pre>")
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
elif t == "table_of_contents":
toc = [x for x in (blocks or [])
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
if toc:
items = "".join(
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
for x in toc
)
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
elif t == "columns":
cols = "".join(
f'<div class="column">{blocks_to_html([child])}</div>'
for child in (b.get("children") or [])
)
parts.append(f'<div class="columns">{cols}</div>')
elif t == "callout":
icon = b.get("icon") or "💡"
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
elif t == "image":
src = b.get("src") or ""
alt = _text(b.get("alt"))
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
elif t == "video":
src = b.get("src") or ""
if src:
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
elif t == "audio":
src = b.get("src") or ""
if src:
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = _text(b.get("title")) or url
desc = _text(b.get("description"))
img = b.get("image") or ""
site = _text(b.get("site_name")) or ""
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
parts.append(
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src") or ""
emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"):
parts.append('<div class="embed-note">[Embedded content]</div>')
elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url:
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
elif url:
from app.services.embeds import embed_src
src = b.get("embed_src") or embed_src(url) or url
height = 520
if b.get("height"):
try:
height = int(b["height"])
except (ValueError, TypeError):
pass
parts.append(
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "table":
parts.append(_table_to_html(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
parts.append(blocks_to_html(resolved))
else:
parts.append(f"<p>{_text(resolved)}</p>")
except Exception:
parts.append(f"<p>[Synced block {synced_id}]</p>")
else:
parts.append(f"<p>{c}</p>")
return "\n".join(parts)
def _standalone_css() -> str:
return """
:root{color-scheme:light;}
*{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;}
.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;}
h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;}
h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;}
h3{font-size:1.35rem;margin:24px 0 8px;}
h4{font-size:1.1rem;margin:20px 0 6px;}
p{margin:8px 0;}
li{margin:4px 0;}
ol{list-style:decimal;padding-left:24px;}
ul{list-style:disc;padding-left:24px;}
blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;}
hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;}
pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;}
code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;}
pre code{background:none;padding:0;font-size:13px;}
.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;}
details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;}
details summary{cursor:pointer;font-weight:600;}
details[open] summary{margin-bottom:8px;}
.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;}
.todo input{margin-top:5px;}
.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;}
.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;}
.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;}
.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;}
.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;}
.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;}
.callout>span{font-size:20px;flex-shrink:0;}
.math{margin:14px 0;overflow-x:auto;}
figure{margin:16px 0;text-align:center;}
figure img{max-width:100%;border-radius:8px;}
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
.ftable th{background:#f6f8fa;font-weight:600;}
.ftable tr:nth-child(even) td{background:#fcfcfd;}
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
a{color:#0969da;}
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
"""
def page_to_standalone_html(
page: dict,
*,
include_children: bool = True,
base_url: str = "",
) -> str:
"""Return a standalone, self-contained HTML document for a page."""
title = _page_title(page)
body = blocks_to_html(_page_blocks(page))
meta_updated = page.get("updated_at") or ""
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
sub_html = ""
if include_children:
for sub in _child_pages(page):
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
sub_html += "</div>"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_text(title)}</title>
<style>{_standalone_css()}</style>
</head>
<body>
<div class="wrap">
<h1>{_text(title)}</h1>
{body}
{sub_html}
{footer}
</div>
</body>
</html>"""
# ═══════════════ PDF ═══════════════
def _pdf_html(page: dict) -> str:
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
title = _page_title(page)
body = blocks_to_html(_page_blocks(page))
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
<style>
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
h1{{font-size:26px;margin:0 0 10px;}}
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
h3{{font-size:16px;margin:18px 0 6px;}}
h4{{font-size:14px;margin:14px 0 4px;}}
p,li{{margin:4px 0;}}
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
code{{font-family:monospace;font-size:10px;}}
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
table{{border-collapse:collapse;width:100%;}}
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
.ftable th{{background:#f0f0f0;font-weight:bold;}}
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
.todo{{margin:4px 0;}}
.math{{font-style:italic;margin:10px 0;}}
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
</style></head><body>
<h1>{_text(title)}</h1>
{body}
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
</body></html>"""
def page_to_pdf_bytes(page: dict) -> bytes:
"""Render a page to a PDF.
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
(needs system libs: pango + fonts; available in the Docker image).
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
absent (e.g. a Windows dev host) — text/table content still exports,
though emoji are limited to monochrome by the engine.
"""
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
try:
from weasyprint import HTML
html = page_to_standalone_html(page, include_children=False)
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
except Exception: # ImportError or missing native libs (OSError) -> fallback
pass
# 2) xhtml2pdf fallback (pure Python)
from xhtml2pdf import pisa
src = _pdf_html(page)
buf = io.BytesIO()
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
if pdf.err:
raise RuntimeError(f"PDF generation failed: {pdf.err}")
return buf.getvalue()
# ═══════════════ Static site (zip) ═══════════════
def _site_index_html(pages: list[dict]) -> str:
"""Build the index.html of the static site (list of all pages)."""
def link(p: dict) -> str:
title = _page_title(p)
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
items = "".join(link(p) for p in pages)
return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>FlowDeck Site</title>
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
def build_static_site_bytes(root_page: dict) -> bytes:
"""Build a full static site as a zip: index.html + one HTML file per page."""
pages = [root_page] + _child_pages(root_page)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("index.html", _site_index_html(pages))
for p in pages:
title = _page_title(p)
name = f"{quote(title, safe='')}.html"
z.writestr(name, page_to_standalone_html(p, include_children=False))
return buf.getvalue()
-72
View File
@@ -1,72 +0,0 @@
"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
``ForgeAdapter`` defines the minimal contract a forge client must expose for the
``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
GitHub implementation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class ForgeAdapter(ABC):
"""Common forge API surface used by FlowDeck (v5.2.0)."""
kind = "base"
@abstractmethod
async def validate_token(self) -> bool:
"""True when the stored credentials still work."""
@abstractmethod
async def list_repos(self, page: int = 1) -> list[dict]:
"""List repositories for the authenticated user."""
@abstractmethod
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata (default_branch, clone_url, language, …)."""
def normalize_repo(repo: dict, proj_type: str) -> dict:
"""Project a forge repo dict onto the ``projects`` table columns."""
full_name = repo.get("full_name", "") or repo.get("fullName", "")
owner, _, name = full_name.partition("/")
return {
"name": name or repo.get("name", ""),
"owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
"proj_type": proj_type,
"forge_id": str(repo.get("id", "") or ""),
"clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
"default_branch": repo.get("default_branch", ""),
"language": repo.get("language", ""),
"description": (repo.get("description") or "") or "",
}
class GiteaAdapter(ForgeAdapter):
"""Adapt the existing GiteaClient to the ForgeAdapter contract."""
kind = "gitea"
def __init__(self, client) -> None: # client = GiteaClient instance
self._client = client
async def validate_token(self) -> bool:
try:
await self._client.get_user_repos(page=1, limit=1)
return True
except Exception:
return False
async def list_repos(self, page: int = 1) -> list[dict]:
return await self._client.get_user_repos(page=page, limit=30)
async def get_repo_info(self, owner: str, repo: str) -> dict:
async with __import__("httpx").AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._client._base}/repos/{owner}/{repo}",
headers=self._client._headers,
)
resp.raise_for_status()
return resp.json()
+3 -3
View File
@@ -1,8 +1,8 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Callable
from datetime import datetime, date, timedelta
from typing import Any, Callable, Optional
class FormulaEngine:
@@ -214,7 +214,7 @@ class FormulaEngine:
return val.split("...")[0]
return val
def _end(self, ctx: dict, s: Any) -> str | None:
def _end(self, ctx: dict, s: Any) -> Optional[str]:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
+8 -125
View File
@@ -1,4 +1,4 @@
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
"""FlowDeck — GitHub API adapter with caching."""
from __future__ import annotations
import base64
@@ -8,36 +8,26 @@ from typing import Any
import httpx
from app.services.forge_adapter import ForgeAdapter
logger = logging.getLogger(__name__)
DEFAULT_TTL = 30 # seconds
class GitHubAdapter(ForgeAdapter):
class GitHubAdapter:
"""Async GitHub API client (v3 REST) with simple TTL cache.
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
interface so Gitea and GitHub repos can be synced identically.
Authenticated via OAuth2 Bearer token.
"""
kind = "github"
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
transport: httpx.BaseTransport | None = None) -> None:
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
self._base = "https://api.github.com"
self._headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
self._transport = transport
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=ttl)
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=15, transport=self._transport)
# ── cache helpers ──
def _cached(self, key: str) -> Any | None:
@@ -58,7 +48,7 @@ class GitHubAdapter(ForgeAdapter):
if cached:
return cached
async with self._client() as client:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
@@ -91,7 +81,7 @@ class GitHubAdapter(ForgeAdapter):
if cached:
return cached
async with self._client() as client:
async with httpx.AsyncClient(timeout=15) as client:
# Resolve default branch commit SHA if not provided
if sha is None:
repo_info = await client.get(
@@ -138,7 +128,7 @@ class GitHubAdapter(ForgeAdapter):
if cached:
return cached
async with self._client() as client:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
@@ -156,118 +146,11 @@ class GitHubAdapter(ForgeAdapter):
self._set_cache(cache_key, content)
return content
# ── repo info (ForgeAdapter) ──
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata: default_branch, clone_url, languages, …"""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name"),
"clone_url": info.get("clone_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
"html_url": info.get("html_url", ""),
}
# Languages are a separate endpoint.
try:
lang_resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
if lang_resp.status_code == 200:
langs = lang_resp.json()
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
self._set_cache(cache_key, repo_info)
return repo_info
async def get_languages(self, owner: str, repo: str) -> dict:
"""Bytes per language for a repo."""
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
resp.raise_for_status()
return resp.json()
# ── issues / labels / milestones ──
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
"""List issues (pull requests are filtered out)."""
issues: list[dict] = []
for page in range(1, 6):
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers,
params={"state": state, "per_page": 100, "page": page},
)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
issues.extend(i for i in batch if "pull_request" not in i)
if len(batch) < 100:
break
return issues
async def list_labels(self, owner: str, repo: str) -> list[dict]:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/labels",
headers=self._headers,
params={"per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/milestones",
headers=self._headers,
params={"state": state, "per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
"""Flatten the repo tree into file entries (``path``, ``size``)."""
tree = await self.get_repo_tree(owner, repo)
prefix = path.strip("/")
files = [
{"path": item["path"], "size": item.get("size", 0)}
for item in tree
if item.get("type") == "blob" and item.get("path")
]
if prefix:
files = [f for f in files if f["path"].startswith(prefix + "/") or f["path"] == prefix]
return files
# ── token validation ──
async def validate_token(self) -> bool:
"""Check whether the access token is still valid."""
async with self._client() as client:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self._base}/user",
headers=self._headers,
-58
View File
@@ -1,58 +0,0 @@
"""FlowDeck — importers package (v5.6.0).
Importing this package registers every built-in importer. Use
:func:`parse_upload` to detect a source and :func:`run_import` to persist it.
"""
from __future__ import annotations
from app.services.importers import ( # noqa: F401 - registration side effects
bookmarks,
calendar,
docx,
html_notes,
markdown,
notion,
obsidian,
opml,
outline,
pdf,
standard_notes,
tabular,
)
from app.services.importers.base import ( # noqa: F401
ImportAttachment,
Importer,
ImportPage,
ImportResult,
all_importers,
detect_importer,
get_importer,
list_sources,
)
from app.services.importers.jobs import ( # noqa: F401
create_job,
get_job,
list_jobs,
parse_upload,
start_import_job,
)
from app.services.importers.pipeline import preview_result, resolve_relations, run_import # noqa: F401
__all__ = [
"ImportAttachment",
"ImportPage",
"ImportResult",
"Importer",
"all_importers",
"detect_importer",
"get_importer",
"list_sources",
"create_job",
"get_job",
"list_jobs",
"parse_upload",
"start_import_job",
"preview_result",
"run_import",
"resolve_relations",
]
-106
View File
@@ -1,106 +0,0 @@
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
from __future__ import annotations
import re
from typing import Any
try: # PyYAML ships transitively via uvicorn[standard]
import yaml
except Exception: # pragma: no cover - fallback parser below
yaml = None
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
raw = m.group(1)
body = text[m.end():]
if yaml is not None:
try:
meta = yaml.safe_load(raw)
if isinstance(meta, dict):
return meta, body
except Exception: # noqa: BLE001 - fall back to the simple parser
pass
return _simple_yaml(raw), body
def _simple_yaml(raw: str) -> dict[str, Any]:
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
meta: dict[str, Any] = {}
current: str | None = None
for line in raw.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if line.lstrip().startswith("- ") and current:
meta.setdefault(current, [])
if isinstance(meta[current], list):
meta[current].append(_scalar(line.lstrip()[2:].strip()))
continue
if ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
current = key
if not value:
meta[key] = []
elif value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
else:
meta[key] = _scalar(value)
return meta
def _scalar(value: str) -> Any:
v = value.strip().strip('"').strip("'")
if v.lower() in ("true", "false"):
return v.lower() == "true"
if re.fullmatch(r"-?\d+", v):
return int(v)
if re.fullmatch(r"-?\d+\.\d+", v):
return float(v)
return v
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
into Markdown images so the block converter can render them."""
def repl(m: re.Match) -> str:
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
label = (alias or target).strip()
if bang == "!":
return f"![{label}]({target})" if embeds else label
return f"[{label}]({target})"
return _WIKILINK_RE.sub(repl, text)
def normalize_title(value: Any) -> str:
return str(value).strip() if value is not None else ""
def coerce_tags(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
if isinstance(value, str):
parts = re.split(r"[,\s]+", value)
return [p.strip().lstrip("#") for p in parts if p.strip()]
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
-147
View File
@@ -1,147 +0,0 @@
"""FlowDeck — unified import framework (v5.6.0, Phase 0).
Defines the normalized data model shared by every importer and the registry
used to auto-detect a source. An :class:`Importer` turns an uploaded file into
an :class:`ImportResult` (pages, attachments, warnings, stats) which the
pipeline (:mod:`app.services.importers.pipeline`) persists into FlowDeck.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
def decode_text(data: bytes) -> str:
"""Best-effort decode of uploaded bytes (BOM aware, latin-1 fallback)."""
for enc in ("utf-8-sig", "utf-8", "utf-16", "latin-1"):
try:
return data.decode(enc)
except (UnicodeDecodeError, UnicodeError):
continue
return data.decode("utf-8", errors="replace")
@dataclass
class ImportAttachment:
"""A binary asset extracted from an archive/vault."""
source_path: str
filename: str
data: bytes = b""
mime: str = ""
@dataclass
class ImportPage:
"""One page to create. ``markdown`` is converted to blocks by the pipeline
unless ``blocks`` is already provided. ``collection`` marks a database
(Notion database, Excel sheet…) whose rows become ``collection_pages``."""
title: str = "Untitled"
markdown: str = ""
blocks: list[dict] = field(default_factory=list)
source_path: str = ""
parent_path: str = ""
properties: dict[str, Any] = field(default_factory=dict)
collection: dict[str, Any] | None = None
external_id: str = ""
page_id: int | None = None
@dataclass
class ImportResult:
"""Normalized output of any importer."""
source: str = ""
pages: list[ImportPage] = field(default_factory=list)
attachments: list[ImportAttachment] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stats: dict[str, Any] = field(default_factory=dict)
def warn(self, message: str) -> None:
if message and message not in self.warnings:
self.warnings.append(message)
def finalize(self) -> ImportResult:
self.stats.setdefault("pages", len(self.pages))
self.stats.setdefault("collections", sum(1 for p in self.pages if p.collection))
self.stats.setdefault("attachments", len(self.attachments))
self.stats.setdefault("warnings", len(self.warnings))
return self
class Importer(ABC):
"""Base class for a source importer."""
source_id: str = ""
label: str = ""
description: str = ""
extensions: tuple[str, ...] = ()
order: int = 100
def detect(self, filename: str, data: bytes) -> bool:
"""Return True when this importer recognizes the uploaded file."""
return False
@abstractmethod
def parse(self, filename: str, data: bytes) -> ImportResult:
"""Parse the upload into a normalized :class:`ImportResult`."""
def info(self) -> dict[str, Any]:
return {
"source_id": self.source_id,
"label": self.label,
"description": self.description,
"extensions": list(self.extensions),
}
REGISTRY: list[Importer] = []
def register_importer(cls: type[Importer]) -> type[Importer]:
"""Class decorator registering an importer instance."""
REGISTRY.append(cls())
REGISTRY.sort(key=lambda i: i.order)
return cls
def all_importers() -> list[Importer]:
return list(REGISTRY)
def get_importer(source_id: str) -> Importer | None:
for imp in REGISTRY:
if imp.source_id == source_id:
return imp
return None
def detect_importer(filename: str, data: bytes) -> Importer | None:
"""First importer that recognizes the file, else None."""
for imp in REGISTRY:
try:
if imp.detect(filename, data):
return imp
except Exception: # noqa: BLE001 - a broken detector must not break detection
continue
return None
def list_sources() -> list[dict[str, Any]]:
return [imp.info() for imp in REGISTRY]
def make_collection(name: str, schema: list[dict], rows: list[dict],
*, source_path: str = "", external_id: str = "") -> ImportPage:
"""Build an ImportPage carrying a collection spec (database import).
``rows`` entries are ``{"title": str, "properties": {name: value}}``.
"""
return ImportPage(
title=name or "Imported database",
collection={"name": name or "Imported database", "schema": schema, "rows": rows},
source_path=source_path or name,
external_id=external_id or source_path or name,
)
-284
View File
@@ -1,284 +0,0 @@
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
normalized into a FlowDeck collection (URL, description, tags, created date).
"""
from __future__ import annotations
import csv
import io
import json
import re
from datetime import UTC, datetime
from typing import Any
from app.services.importers._common import coerce_tags
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "URL", "type": "url"},
{"name": "Description", "type": "text"},
{"name": "Tags", "type": "multi_select"},
{"name": "Created", "type": "date"},
]
_TAG_RE = re.compile(
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
re.IGNORECASE | re.DOTALL,
)
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
def _strip_tags(value: str) -> str:
return re.sub(r"<[^>]+>", "", value or "").strip()
def _iso_from_epoch(value: Any) -> str:
try:
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
except (ValueError, TypeError, OSError, OverflowError):
return ""
def _iso(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d{10}", text):
return _iso_from_epoch(text)
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
props: dict[str, Any] = {}
if url:
props["URL"] = url
if description:
props["Description"] = description
tag_list = coerce_tags(tags)
if tag_list:
props["Tags"] = tag_list
if created:
props["Created"] = created
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
def parse_netscape(html: str) -> list[dict]:
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
rows: list[dict] = []
folder = ""
for match in _TAG_RE.finditer(html):
if match.group("folder") is not None:
folder = _strip_tags(match.group("folder"))
continue
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
url = attrs.get("href") or attrs.get("HREF") or ""
if not url:
continue
title = _strip_tags(match.group("title"))
tags = attrs.get("tags") or attrs.get("TAGS") or folder
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
return rows
def _csv_rows(text: str) -> list[dict]:
reader = csv.DictReader(io.StringIO(text))
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
class _BookmarkBase(Importer):
source_id = "bookmarks"
label = "Signets"
description = ""
order = 44
keywords: tuple[str, ...] = ()
def _hint(self, filename: str, text: str) -> bool:
low = filename.lower()
return any(k in low or k in text.lower() for k in self.keywords)
@register_importer
class RaindropImporter(_BookmarkBase):
source_id = "raindrop"
label = "Raindrop.io"
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
extensions = (".csv", ".html", ".htm")
order = 46
keywords = ("raindrop",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
return filename.lower().endswith((".csv", ".html", ".htm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
rows.append(_row(
r.get("title", ""), r.get("url", ""),
r.get("note") or r.get("excerpt") or "",
r.get("tags", ""), _iso(r.get("created", "")),
))
else:
rows = parse_netscape(text)
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class PocketImporter(_BookmarkBase):
source_id = "pocket"
label = "Pocket"
description = "Export Pocket (CSV ou HTML) → collection de signets."
extensions = (".csv", ".html", ".htm")
order = 45
keywords = ("pocket",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
return filename.lower().endswith((".csv", ".html", ".htm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
rows.append(_row(
r.get("title", ""), r.get("url", ""),
"", r.get("tags", ""), _iso(r.get("time_added", "")),
))
else:
rows = parse_netscape(text)
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ReadwiseImporter(_BookmarkBase):
source_id = "readwise"
label = "Readwise"
description = "Export Readwise (highlights CSV) → collection de surlignages."
extensions = (".csv", ".md", ".markdown")
order = 47
keywords = ("readwise",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
if filename.lower().endswith(".csv"):
header = text.splitlines()[0].lower() if text.strip() else ""
return "highlight" in header or "book title" in header
return True
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
schema = [
{"name": "Highlight", "type": "title"},
{"name": "Book", "type": "text"},
{"name": "Author", "type": "text"},
{"name": "Note", "type": "text"},
{"name": "Tags", "type": "multi_select"},
{"name": "Highlighted at", "type": "date"},
]
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
props: dict[str, Any] = {}
if r.get("book title"):
props["Book"] = r["book title"]
if r.get("book author"):
props["Author"] = r["book author"]
if r.get("note"):
props["Note"] = r["note"]
tags = coerce_tags(r.get("document tags") or r.get("tags"))
if tags:
props["Tags"] = tags
created = _iso(r.get("highlighted at", ""))
if created:
props["Highlighted at"] = created
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
else:
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ShaarliImporter(_BookmarkBase):
source_id = "shaarli"
label = "Shaarli"
description = "Export Shaarli (JSON) → collection de signets."
extensions = (".json",)
order = 48
keywords = ("shaarli",)
def _records(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
obj = obj["links"]
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
return obj
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
return self._records(data) is not None
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
records = self._records(data) or []
rows = [
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
r.get("tags", ""), _iso(r.get("created", "")))
for r in records
]
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class BookmarksImporter(_BookmarkBase):
source_id = "bookmarks"
label = "Signets HTML (navigateur)"
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
extensions = (".html", ".htm")
order = 49
keywords = ()
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith((".html", ".htm")):
return False
text = decode_text(data)[:4000].lower()
return "netscape-bookmark-file" in text or "<dt><a href" in text
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
rows = parse_netscape(decode_text(data))
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
-138
View File
@@ -1,138 +0,0 @@
"""FlowDeck — iCalendar (.ics) importer (v5.6.0, Phase 4).
Parses VEVENT blocks (RFC 5545, best-effort) into a FlowDeck calendar
collection (start/end, all-day, location, description).
"""
from __future__ import annotations
import re
from typing import Any
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Start", "type": "date"},
{"name": "End", "type": "date"},
{"name": "All day", "type": "checkbox"},
{"name": "Location", "type": "text"},
{"name": "Description", "type": "text"},
{"name": "Calendar", "type": "text"},
]
_UNESCAPE = [("\\n", "\n"), ("\\N", "\n"), ("\\,", ","), ("\\;", ";"), ("\\\\", "\\")]
def _unfold(text: str) -> list[str]:
lines: list[str] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if raw[:1] in (" ", "\t") and lines:
lines[-1] += raw[1:]
else:
lines.append(raw)
return lines
def _unescape(value: str) -> str:
for src, dst in _UNESCAPE:
value = value.replace(src, dst)
return value.strip()
def _parse_prop(line: str) -> tuple[str, dict[str, str], str]:
if ":" not in line:
return "", {}, ""
head, _, value = line.partition(":")
parts = head.split(";")
name = parts[0].upper()
params: dict[str, str] = {}
for p in parts[1:]:
if "=" in p:
k, _, v = p.partition("=")
params[k.upper()] = v
return name, params, value
def _iso_datetime(value: str, params: dict[str, str]) -> tuple[str, bool]:
"""Return (iso, is_all_day)."""
v = value.strip()
if params.get("VALUE") == "DATE" or re.fullmatch(r"\d{8}", v):
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", v)
return (f"{m.group(1)}-{m.group(2)}-{m.group(3)}", True) if m else ("", True)
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", v)
if not m:
return v, False
date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}"
return (date + "+00:00" if m.group(7) else date), False
@register_importer
class IcsImporter(Importer):
source_id = "ics"
label = "Calendrier (.ics)"
description = "Export iCalendar (Google/Outlook/Apple) → collection d'événements."
extensions = (".ics", ".ical")
order = 33
def detect(self, filename: str, data: bytes) -> bool:
if filename.lower().endswith((".ics", ".ical")):
return True
return "BEGIN:VCALENDAR" in decode_text(data)[:2000]
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
lines = _unfold(decode_text(data))
calendar = ""
rows: list[dict] = []
event: dict[str, Any] | None = None
for line in lines:
upper = line.strip().upper()
if upper == "BEGIN:VEVENT":
event = {}
continue
if upper == "END:VEVENT":
if event is not None:
rows.append(_event_row(event, calendar))
event = None
continue
name, params, value = _parse_prop(line.strip())
if name == "X-WR-CALNAME" and not event:
calendar = _unescape(value)
if event is None:
continue
if name == "SUMMARY":
event["title"] = _unescape(value)
elif name == "DTSTART":
event["start"], event["all_day"] = _iso_datetime(value, params)
elif name == "DTEND":
event["end"], _ = _iso_datetime(value, params)
elif name == "LOCATION":
event["location"] = _unescape(value)
elif name == "DESCRIPTION":
event["description"] = _unescape(value)
elif name == "UID":
event["uid"] = value
result.pages.append(make_collection("Calendar", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
def _event_row(event: dict[str, Any], calendar: str) -> dict:
props: dict[str, Any] = {}
if event.get("start"):
props["Start"] = event["start"]
if event.get("end"):
props["End"] = event["end"]
props["All day"] = bool(event.get("all_day"))
if event.get("location"):
props["Location"] = event["location"]
if event.get("description"):
props["Description"] = event["description"]
if calendar:
props["Calendar"] = calendar
return {"title": (event.get("title") or "Event").strip()[:200], "properties": props}
-122
View File
@@ -1,122 +0,0 @@
"""FlowDeck — Word (.docx) importer (v5.6.0, Phase 3).
Converts a Word document (including Google Docs Takeout ``.docx`` exports) into
a FlowDeck page: headings, lists, tables and inline images.
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportResult,
register_importer,
)
_HEADING_STYLES = {
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
"heading 4": 4, "heading 5": 4, "heading 6": 4,
}
_MIME = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".emf": "image/emf", ".wmf": "image/wmf", ".tiff": "image/tiff",
}
def _escape_cell(text: str) -> str:
return text.strip().replace("|", "\\|").replace("\n", " ")
def _table_markdown(table) -> str:
rows: list[list[str]] = []
for row in table.rows:
rows.append([_escape_cell(cell.text) for cell in row.cells])
if not rows:
return ""
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
header = "| " + " | ".join(rows[0]) + " |"
sep = "| " + " | ".join(["---"] * width) + " |"
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
return "\n".join(x for x in (header, sep, body) if x)
@register_importer
class DocxImporter(Importer):
source_id = "docx"
label = "Word / Google Docs (.docx)"
description = "Document Word : titres, listes, tableaux et images."
extensions = (".docx", ".docm")
order = 36
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith((".docx", ".docm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from docx import Document
from docx.oxml.ns import qn
except ImportError:
result.warn("python-docx n'est pas installé : import Word indisponible")
return result.finalize()
try:
doc = Document(io.BytesIO(data))
except Exception as exc: # noqa: BLE001
result.warn(f"Document illisible : {exc}")
return result.finalize()
lines: list[str] = []
for para in doc.paragraphs:
text = para.text.strip()
style = (para.style.name or "").lower() if para.style else ""
images = self._paragraph_images(doc, para, qn, result)
if text:
level = _HEADING_STYLES.get(style)
if level:
lines.append("#" * level + " " + text)
elif "list bullet" in style or "list paragraph" in style:
lines.append("- " + text)
elif "list number" in style:
lines.append("1. " + text)
elif style == "quote":
lines.append("> " + text)
else:
lines.append(text)
lines.extend(images)
for table in doc.tables:
md = _table_markdown(table)
if md:
lines.append(md)
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(lines)).strip()
title = Path(filename).stem or "Document"
result.pages.append(_page(title, markdown, filename))
return result.finalize()
def _paragraph_images(self, doc, para, qn, result: ImportResult) -> list[str]:
images: list[str] = []
for blip in para._p.iter(qn("a:blip")):
rid = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
if not rid:
continue
part = doc.part.related_parts.get(rid)
if part is None or not hasattr(part, "blob"):
continue
name = Path(str(part.partname)).name or f"image_{len(result.attachments)}.png"
result.attachments.append(ImportAttachment(
source_path=name, filename=name, data=part.blob,
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
))
images.append(f"![{name}]({name})")
return images
def _page(title: str, markdown: str, filename: str):
from app.services.importers.base import ImportPage
return ImportPage(title=title, markdown=markdown, source_path=filename, external_id=filename)
-232
View File
@@ -1,232 +0,0 @@
"""FlowDeck — forge (Gitea/GitHub) issues importer (v5.6.0, Phase 4).
Pulls a repository's issues, labels and milestones through a forge adapter and
normalizes them into FlowDeck collections.
"""
from __future__ import annotations
from typing import Any
from app.services.importers.base import ImportResult, make_collection
def _label_names(issue: dict) -> list[str]:
labels = issue.get("labels") or []
names: list[str] = []
for label in labels:
if isinstance(label, dict):
name = label.get("name") or label.get("title")
else:
name = str(label)
if name and name not in names:
names.append(name)
return names
def _milestone_name(issue: dict) -> str:
milestone = issue.get("milestone")
if isinstance(milestone, dict):
return str(milestone.get("title") or milestone.get("name") or "")
return str(milestone or "")
def _assignees(issue: dict) -> list[str]:
out: list[str] = []
for key in ("assignees", "assignee"):
value = issue.get(key)
if isinstance(value, list):
for a in value:
login = a.get("login") if isinstance(a, dict) else str(a)
if login and login not in out:
out.append(login)
elif isinstance(value, dict):
login = value.get("login")
if login and login not in out:
out.append(login)
elif isinstance(value, str) and value and value not in out:
out.append(value)
return out
_ISSUE_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Number", "type": "number"},
{"name": "State", "type": "select", "options": [
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
]},
{"name": "Labels", "type": "multi_select"},
{"name": "Milestone", "type": "text"},
{"name": "Assignee", "type": "text"},
{"name": "Created", "type": "date"},
{"name": "Updated", "type": "date"},
{"name": "URL", "type": "url"},
{"name": "Body", "type": "text"},
]
def build_issues_result(
issues: list[dict],
*,
labels: list[dict] | None = None,
milestones: list[dict] | None = None,
owner: str = "",
repo: str = "",
provider: str = "",
) -> ImportResult:
"""Normalize forge issues/labels/milestones into an ImportResult."""
result = ImportResult(source=f"forge:{provider}" if provider else "forge")
name = f"{owner}/{repo} issues".strip("/ ") or "Issues"
rows: list[dict] = []
for issue in issues:
if issue.get("pull_request"):
continue
props: dict[str, Any] = {}
if issue.get("number") is not None:
props["Number"] = issue["number"]
if issue.get("state"):
props["State"] = issue["state"]
label_names = _label_names(issue)
if label_names:
props["Labels"] = label_names
milestone = _milestone_name(issue)
if milestone:
props["Milestone"] = milestone
assignees = _assignees(issue)
if assignees:
props["Assignee"] = ", ".join(assignees)
if issue.get("created_at"):
props["Created"] = issue["created_at"]
if issue.get("updated_at"):
props["Updated"] = issue["updated_at"]
if issue.get("html_url"):
props["URL"] = issue["html_url"]
if issue.get("body"):
props["Body"] = issue["body"]
title = issue.get("title") or f"#{issue.get('number', '')}".strip()
rows.append({"title": title[:200], "properties": props})
result.pages.append(make_collection(
name, _ISSUE_SCHEMA, rows,
source_path=f"{provider}:{owner}/{repo}:issues",
external_id=f"{provider}:{owner}/{repo}:issues",
))
result.stats["rows"] = len(rows)
if labels:
label_schema = [
{"name": "Name", "type": "title"},
{"name": "Color", "type": "text"},
{"name": "Description", "type": "text"},
]
label_rows = [{
"title": (lbl.get("name") or lbl.get("title") or "Label")[:200],
"properties": {
k: v for k, v in (
("Color", lbl.get("color")),
("Description", lbl.get("description")),
) if v
},
} for lbl in labels]
result.pages.append(make_collection(
f"{owner}/{repo} labels".strip("/ "), label_schema, label_rows,
source_path=f"{provider}:{owner}/{repo}:labels",
external_id=f"{provider}:{owner}/{repo}:labels",
))
if milestones:
ms_schema = [
{"name": "Title", "type": "title"},
{"name": "State", "type": "select", "options": [
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Description", "type": "text"},
]
ms_rows = []
for ms in milestones:
props: dict[str, Any] = {}
if ms.get("state"):
props["State"] = ms["state"]
if ms.get("due_on"):
props["Due date"] = ms["due_on"]
if ms.get("description"):
props["Description"] = ms["description"]
ms_rows.append({"title": (ms.get("title") or "Milestone")[:200], "properties": props})
result.pages.append(make_collection(
f"{owner}/{repo} milestones".strip("/ "), ms_schema, ms_rows,
source_path=f"{provider}:{owner}/{repo}:milestones",
external_id=f"{provider}:{owner}/{repo}:milestones",
))
return result.finalize()
class GiteaForgeAdapter:
"""Adapts a :class:`GiteaClient` to the ``list_*`` interface used here."""
def __init__(self, client) -> None:
self._client = client
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
issues: list[dict] = []
for page in range(1, 6):
batch = await self._client.get_issues(owner, repo, state=state, page=page, limit=50)
if not batch:
break
issues.extend(batch)
if len(batch) < 50:
break
return issues
async def list_labels(self, owner: str, repo: str) -> list[dict]:
return await self._client.get_labels(owner, repo)
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
return await self._client.get_milestones(owner, repo, state=state)
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
"""Recursively flatten Gitea repo contents into file entries."""
files: list[dict] = []
pending = [path.strip("/")]
while pending and len(files) < 5000:
current = pending.pop()
items = await self._client.get_repo_contents(owner, repo, current)
for item in items:
if item.get("type") == "dir":
pending.append(item.get("path") or item.get("name"))
elif item.get("type") == "file":
files.append({"path": item.get("path") or item.get("name"), "size": item.get("size", 0)})
return files
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
return await self._client.get_file_content(owner, repo, path)
async def fetch_forge_issues(
adapter,
owner: str,
repo: str,
*,
provider: str = "",
state: str = "all",
include_labels: bool = True,
include_milestones: bool = True,
) -> ImportResult:
"""Fetch issues (and optionally labels/milestones) then normalize them."""
issues = await adapter.list_issues(owner, repo, state)
labels = None
milestones = None
if include_labels:
try:
labels = await adapter.list_labels(owner, repo)
except Exception: # noqa: BLE001 - labels are optional
labels = None
if include_milestones:
try:
milestones = await adapter.list_milestones(owner, repo, state)
except Exception: # noqa: BLE001
milestones = None
return build_issues_result(
issues, labels=labels, milestones=milestones,
owner=owner, repo=repo, provider=provider,
)
-85
View File
@@ -1,85 +0,0 @@
"""FlowDeck — forge repository file importer (v5.6.0, Phase 5).
Imports a Gitea/GitHub repository's text files as pages, preserving the folder
hierarchy. Markdown files become pages; other text files become code blocks.
"""
from __future__ import annotations
from pathlib import Path
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
from app.services.importers.base import ImportPage, ImportResult
_MD_EXTS = {"md", "markdown"}
def _ext(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def build_repo_result(
files: list[tuple[str, str]],
*,
owner: str,
repo: str,
provider: str = "",
) -> ImportResult:
"""Turn ``[(path, content)]`` into pages with folder hierarchy."""
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
for path, content in files:
clean = path.replace("\\", "/").strip("/")
if not clean:
continue
ext = _ext(clean)
if ext in _MD_EXTS:
markdown = content
else:
lang = _CODE_LANG.get(ext, "")
markdown = f"```{lang}\n{content.rstrip()}\n```"
parts = clean.split("/")
result.pages.append(ImportPage(
title=parts[-1] or clean,
markdown=markdown,
source_path=clean,
parent_path="/".join(parts[:-1]),
external_id=f"{provider}:{owner}/{repo}:{clean}",
))
result.stats["rows"] = len(result.pages)
return result.finalize()
async def fetch_forge_repo(
adapter,
owner: str,
repo: str,
*,
provider: str = "",
path: str = "",
max_files: int = 200,
max_file_bytes: int = 512_000,
) -> ImportResult:
"""List a repo's files and fetch the textual ones."""
try:
metas = await adapter.list_repo_files(owner, repo, path)
except Exception as exc: # noqa: BLE001
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
result.warn(f"Arborescence illisible : {exc}")
return result.finalize()
files: list[tuple[str, str]] = []
for meta in metas:
file_path = meta.get("path") or ""
if _ext(file_path) not in _TEXTUAL_EXTS:
continue
if int(meta.get("size") or 0) > max_file_bytes:
continue
if len(files) >= max_files:
break
try:
content = await adapter.get_file_content(owner, repo, file_path)
except Exception: # noqa: BLE001 - skip unreadable files
continue
if not content or content == "[binary file]":
continue
files.append((file_path, content))
return build_repo_result(files, owner=owner, repo=repo, provider=provider)
-300
View File
@@ -1,300 +0,0 @@
"""FlowDeck — HTML notes & Google Keep importer (v5.6.0, Phase 1).
Covers HTML exports from Apple Notes, Bear, Ulysses and OneNote, plus the
Google Takeout ``Keep`` JSON/HTML format. HTML is converted to Markdown and then
to FlowDeck blocks by the pipeline.
"""
from __future__ import annotations
import io
import json
import re
import zipfile
from bs4 import BeautifulSoup, NavigableString, Tag
from app.services.importers._common import coerce_tags, normalize_title
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_HTML_EXTS = (".html", ".htm")
def _inline(node: Tag) -> str:
out: list[str] = []
for child in node.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif isinstance(child, Tag):
name = child.name.lower()
if name in ("strong", "b"):
out.append(f"**{_inline(child).strip()}**")
elif name in ("em", "i"):
out.append(f"*{_inline(child).strip()}*")
elif name == "code":
out.append(f"`{child.get_text()}`")
elif name == "br":
out.append("\n")
elif name == "a":
href = child.get("href", "")
label = _inline(child).strip() or href
out.append(f"[{label}]({href})" if href else label)
elif name == "img":
src = child.get("src", "")
alt = child.get("alt", "")
out.append(f"![{alt}]({src})" if src else "")
elif name in ("del", "s", "strike"):
out.append(f"~~{_inline(child).strip()}~~")
else:
out.append(_inline(child))
return re.sub(r"[ \t]+", " ", "".join(out))
def _table(node: Tag) -> str:
rows: list[list[str]] = []
for tr in node.find_all("tr"):
cells = tr.find_all(["th", "td"])
rows.append([_inline(c).strip().replace("|", "\\|") for c in cells])
if not rows:
return ""
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
header = "| " + " | ".join(rows[0]) + " |"
sep = "| " + " | ".join(["---"] * width) + " |"
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
return "\n".join(x for x in (header, sep, body) if x)
def _block(node: Tag, depth: int = 0) -> str:
name = node.name.lower()
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
return "#" * int(name[1]) + " " + _inline(node).strip()
if name == "p":
return _inline(node).strip()
if name in ("ul", "ol"):
lines = []
for i, li in enumerate(node.find_all("li", recursive=False)):
marker = f"{i + 1}." if name == "ol" else "-"
text = _inline(li).strip()
lines.append(f"{' ' * depth}{marker} {text}")
return "\n".join(lines)
if name == "blockquote":
return "\n".join(f"> {ln}" for ln in _inline(node).strip().splitlines())
if name == "pre":
code = node.get_text()
lang = ""
cls = " ".join(node.get("class", [])) if node.get("class") else ""
m = re.search(r"(?:language|lang)-([\w+-]+)", cls)
if m:
lang = m.group(1)
return f"```{lang}\n{code.rstrip()}\n```"
if name == "hr":
return "---"
if name == "table":
return _table(node)
if name == "img":
src = node.get("src", "")
return f"![{node.get('alt', '')}]({src})" if src else ""
if name in ("div", "section", "article", "body", "main", "html", "span", "font", "center"):
inner = "\n\n".join(
_block(c, depth) for c in node.children if isinstance(c, Tag)
).strip()
if inner:
return inner
text = _inline(node).strip()
return text
return _inline(node).strip()
def _html_to_markdown(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "head", "nav", "footer"]):
tag.decompose()
root = soup.body or soup
blocks = [_block(c) for c in root.children if isinstance(c, Tag)]
md = "\n\n".join(b for b in blocks if b and b.strip())
return re.sub(r"\n{3,}", "\n\n", md).strip()
def _title_from_html(html: str, fallback: str) -> str:
soup = BeautifulSoup(html, "html.parser")
if soup.title and soup.title.string:
return soup.title.string.strip()
h1 = soup.find(["h1", "h2"])
if h1:
return h1.get_text().strip()
return fallback
@register_importer
class HtmlNotesImporter(Importer):
source_id = "html_notes"
label = "HTML (Apple Notes, Bear, Ulysses, OneNote)"
description = "Fichiers HTML ou archive .zip (notes exportées en HTML)."
extensions = (".html", ".htm", ".zip")
order = 50
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(_HTML_EXTS):
return True
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
return any(n.lower().endswith(_HTML_EXTS) for n in names)
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
entries: list[tuple[str, bytes]] = []
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
for name in zf.namelist():
if name.endswith("/"):
continue
clean = name.replace("\\", "/")
if clean.lower().endswith(_HTML_EXTS):
entries.append((clean, zf.read(name)))
else:
result.attachments.append(ImportAttachment(
source_path=clean,
filename=clean.rsplit("/", 1)[-1],
data=zf.read(name),
))
else:
entries.append((filename, data))
for name, payload in entries:
html = decode_text(payload)
fallback = name.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
parts = name.replace("\\", "/").split("/")
result.pages.append(ImportPage(
title=_title_from_html(html, fallback) or "Untitled",
markdown=_html_to_markdown(html),
source_path=name,
parent_path="/".join(parts[:-1]),
external_id=name,
))
return result.finalize()
@register_importer
class GoogleKeepImporter(Importer):
source_id = "google_keep"
label = "Google Keep (Takeout)"
description = "Export Google Takeout : Keep/*.json (notes, listes, labels, pièces jointes)."
extensions = (".json", ".zip")
order = 40
def _is_keep_json(self, data: bytes) -> bool:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return False
return isinstance(obj, dict) and any(
k in obj for k in ("textContent", "listContent", "isTrashed", "color")
)
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(".json"):
return self._is_keep_json(data)
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
for n in zf.namelist():
if n.lower().endswith(".json") and "keep" in n.lower():
try:
if self._is_keep_json(zf.read(n)):
return True
except Exception: # noqa: BLE001
continue
return False
def _page_from_keep(self, obj: dict, name: str) -> ImportPage | None:
if obj.get("isTrashed"):
return None
title = normalize_title(obj.get("title"))
lines: list[str] = []
for item in obj.get("listContent") or []:
mark = "x" if item.get("isChecked") else " "
lines.append(f"- [{mark}] {item.get('text', '')}")
if obj.get("textContent"):
lines.insert(0, obj["textContent"])
body = "\n\n".join(lines)
if not title:
first = next((ln for ln in body.splitlines() if ln.strip()), "")
first = re.sub(r"^[-*+]\s*(\[[ xX]\]\s*)?", "", first).strip()
title = first[:60] or "Note"
labels = coerce_tags(obj.get("labels"))
props = {"tags": labels} if labels else {}
return ImportPage(
title=title,
markdown=body,
source_path=name,
parent_path="",
properties=props,
external_id=name,
)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
for name in zf.namelist():
if name.endswith("/"):
continue
clean = name.replace("\\", "/")
if clean.lower().endswith(".json") and "keep" in clean.lower():
try:
obj = json.loads(decode_text(zf.read(name)))
except Exception: # noqa: BLE001
continue
if not isinstance(obj, dict):
continue
page = self._page_from_keep(obj, clean)
if page:
result.pages.append(page)
elif "/keep/" in clean.lower() and not clean.lower().endswith(".json"):
result.attachments.append(ImportAttachment(
source_path=clean,
filename=clean.rsplit("/", 1)[-1],
data=zf.read(name),
))
return result.finalize()
try:
obj = json.loads(decode_text(data))
except Exception as exc: # noqa: BLE001
result.warn(f"JSON invalide : {exc}")
return result.finalize()
if isinstance(obj, list):
for i, item in enumerate(obj):
if isinstance(item, dict):
page = self._page_from_keep(item, f"{filename}#{i}")
if page:
result.pages.append(page)
else:
page = self._page_from_keep(obj, filename)
if page:
result.pages.append(page)
return result.finalize()
-150
View File
@@ -1,150 +0,0 @@
"""FlowDeck — background import jobs (v5.6.0, Phase 0).
Small in-process job manager used for large uploads (vaults, zips): the upload
is parsed and persisted in a worker thread while the UI polls job status.
"""
from __future__ import annotations
import threading
import time
import traceback
import uuid
from typing import Any
from app.db import get_conn
from app.services.importers.base import (
Importer,
ImportResult,
detect_importer,
get_importer,
)
from app.services.importers.pipeline import run_import
_JOBS: dict[str, dict[str, Any]] = {}
_LOCK = threading.Lock()
def parse_upload(filename: str, data: bytes, source_id: str | None = None) -> tuple[Importer | None, ImportResult]:
"""Detect (or use) an importer and parse the upload synchronously."""
imp = get_importer(source_id) if source_id else None
if imp is None:
imp = detect_importer(filename, data)
if imp is None:
return None, ImportResult(source=source_id or "unknown", warnings=["Format non reconnu"])
return imp, imp.parse(filename, data)
def _record(job: dict, *, status: str | None = None, error: str = "",
report: dict | None = None, progress: int | None = None) -> None:
with _LOCK:
if status:
job["status"] = status
if error:
job["error"] = error
if report is not None:
job["report"] = report
if progress is not None:
job["progress"] = progress
job["updated_at"] = time.time()
_persist(job)
def _persist(job: dict) -> None:
try:
with get_conn() as conn:
conn.execute(
"INSERT INTO import_jobs (id, source, filename, status, error, report_json, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, "
"report_json=excluded.report_json, updated_at=excluded.updated_at",
(job["id"], job["source"], job["filename"], job["status"], job.get("error", ""),
_json(job.get("report")), job["created_at"], job["updated_at"]),
)
conn.commit()
except Exception: # noqa: BLE001 - persistence is best-effort
pass
def _json(value: Any) -> str:
import json
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return "{}"
def create_job(source: str, filename: str) -> dict:
job = {
"id": uuid.uuid4().hex[:16],
"source": source,
"filename": filename,
"status": "queued",
"progress": 0,
"error": "",
"report": None,
"created_at": time.time(),
"updated_at": time.time(),
}
with _LOCK:
_JOBS[job["id"]] = job
_persist(job)
return job
def get_job(job_id: str) -> dict | None:
with _LOCK:
job = _JOBS.get(job_id)
return dict(job) if job else None
def list_jobs(limit: int = 50) -> list[dict]:
with _LOCK:
jobs = sorted(_JOBS.values(), key=lambda j: j["created_at"], reverse=True)
return [dict(j) for j in jobs[:limit]]
def start_import_job(
*,
filename: str,
data: bytes,
source_id: str | None,
workspace_id: int | None,
workspace_name: str | None,
user_login: str,
parent_page_id: int | None,
target_collection_id: int | None,
dedup: bool = True,
mapping: dict[str, str] | None = None,
mode: str | None = None,
) -> dict:
"""Create a job and run parse + persist in a background thread."""
job = create_job(source_id or "auto", filename)
_record(job, status="running", progress=5)
def worker() -> None:
try:
imp, result = parse_upload(filename, data, source_id)
if imp is None:
_record(job, status="error", error="Format non reconnu")
return
_record(job, progress=40)
report = run_import(
result,
workspace_id=workspace_id,
workspace_name=workspace_name,
user_login=user_login,
parent_page_id=parent_page_id,
target_collection_id=target_collection_id,
dedup=dedup,
mapping=mapping,
mode=mode,
)
_record(job, status="done", progress=100, report=report)
except Exception as exc: # noqa: BLE001 - surface the error to the UI
_record(job, status="error", error=f"{exc}", report={
"traceback": traceback.format_exc()[-2000:],
})
threading.Thread(target=worker, name=f"import-{job['id']}", daemon=True).start()
return job
-87
View File
@@ -1,87 +0,0 @@
"""FlowDeck — generic Markdown / text importer (v5.6.0, Phase 1)."""
from __future__ import annotations
import io
import zipfile
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_MD_EXTS = (".md", ".markdown", ".txt", ".mdx")
def _title_from_name(name: str) -> str:
base = name.replace("\\", "/").rsplit("/", 1)[-1]
for ext in (".markdown", ".markdown", ".mdx", ".md", ".txt"):
if base.lower().endswith(ext):
base = base[: -len(ext)]
break
return base.strip() or "Untitled"
@register_importer
class MarkdownImporter(Importer):
source_id = "markdown"
label = "Markdown / texte"
description = "Fichiers .md/.markdown/.txt ou archive .zip de fichiers Markdown."
extensions = (".md", ".markdown", ".txt", ".zip")
order = 90
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(_MD_EXTS):
return True
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
return bool(names) and all(n.lower().endswith(_MD_EXTS) for n in names)
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
entries = sorted(
(n for n in zf.namelist()
if not n.endswith("/") and n.lower().endswith(_MD_EXTS)),
key=lambda n: (n.count("/"), n.lower()),
)
if not entries:
result.warn("Aucun fichier Markdown trouvé dans l'archive")
return result.finalize()
for name in entries:
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
parts = name.replace("\\", "/").split("/")
result.pages.append(ImportPage(
title=_title_from_name(name),
markdown=text,
source_path=name,
parent_path="/".join(parts[:-1]),
external_id=name,
))
return result.finalize()
text = decode_text(data)
result.pages.append(ImportPage(
title=_title_from_name(filename),
markdown=text,
source_path=filename,
external_id=filename,
))
return result.finalize()
-135
View File
@@ -1,135 +0,0 @@
"""FlowDeck — Notion export importer (v5.6.0, Phase 1, amélioration v5.4.0).
Imports a Notion "Export as Markdown & CSV" ``.zip``: complete page hierarchy,
databases (``.csv``) turned into FlowDeck collections, and image attachments.
"""
from __future__ import annotations
import csv
import io
import re
import zipfile
from urllib.parse import unquote
from app.services.importers._common import split_frontmatter
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
from app.services.importers.tabular import rows_to_collection
_HASH_RE = re.compile(r"\s+[0-9a-f]{32}$")
_MD_LINK_RE = re.compile(r"\]\(([^)]+)\.md\)")
def _clean_name(name: str) -> str:
base = unquote(name.replace("\\", "/").rsplit("/", 1)[-1])
base = re.sub(r"\.(md|csv|markdown)$", "", base, flags=re.IGNORECASE)
return _HASH_RE.sub("", base).strip() or "Untitled"
def _strip_hash_link(match: re.Match) -> str:
target = unquote(match.group(1)).strip()
return f"]({_HASH_RE.sub('', target).strip() or target})"
@register_importer
class NotionImporter(Importer):
source_id = "notion"
label = "Notion (export .zip)"
description = "Export Notion Markdown & CSV : hiérarchie, databases → collections, images."
extensions = (".zip",)
order = 20
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".zip"):
return False
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
md = [n for n in names if n.lower().endswith(".md")]
csvs = [n for n in names if n.lower().endswith(".csv")]
if not md:
return False
if csvs:
return True
return any(_HASH_RE.search(unquote(n.rsplit("/", 1)[-1])) for n in md)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
md_names = [n for n in names if n.lower().endswith(".md")]
csv_names = [n for n in names if n.lower().endswith(".csv")]
pages_by_title: dict[str, ImportPage] = {}
for name in sorted(md_names, key=lambda n: (n.count("/"), n.lower())):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
meta, body = split_frontmatter(text)
title = _clean_name(name)
body = _MD_LINK_RE.sub(_strip_hash_link, body)
first_line = body.lstrip().splitlines()[0] if body.strip() else ""
if first_line.strip().startswith("# ") and first_line.strip()[2:].strip() == title:
body = "\n".join(body.lstrip().splitlines()[1:]).lstrip("\n")
parts = unquote(name).replace("\\", "/").split("/")
page = ImportPage(
title=title,
markdown=body,
source_path=name,
parent_path="/".join(parts[:-1]),
properties={k: v for k, v in meta.items() if k != "title"},
external_id=name,
)
pages_by_title.setdefault(title, page)
result.pages.append(page)
for name in sorted(csv_names, key=lambda n: (n.count("/"), n.lower())):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
reader = csv.DictReader(io.StringIO(text))
headers = [h for h in (reader.fieldnames or []) if h is not None]
rows = [dict(r) for r in reader]
title = _clean_name(name)
spec_page = rows_to_collection(title, headers, rows)
parts = unquote(name).replace("\\", "/").split("/")
existing = pages_by_title.get(title)
if existing is not None:
existing.collection = spec_page.collection
existing.source_path = existing.source_path or name
else:
spec_page.parent_path = "/".join(parts[:-1])
spec_page.source_path = name
spec_page.external_id = name
result.pages.append(spec_page)
for name in names:
low = name.lower()
if low.endswith((".md", ".csv")):
continue
try:
result.attachments.append(ImportAttachment(
source_path=name,
filename=unquote(name).replace("\\", "/").rsplit("/", 1)[-1],
data=zf.read(name),
))
except Exception: # noqa: BLE001
continue
return result.finalize()
-118
View File
@@ -1,118 +0,0 @@
"""FlowDeck — Obsidian vault importer (v5.6.0, Phase 1).
Imports a vault exported as a ``.zip``: Markdown notes (with YAML frontmatter),
the folder hierarchy, ``[[wikilinks]]``/``![[embeds]]`` and binary attachments.
"""
from __future__ import annotations
import io
import zipfile
from app.services.importers._common import (
coerce_tags,
convert_wikilinks,
normalize_title,
split_frontmatter,
)
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_SKIP_DIRS = (".obsidian/", ".trash/", ".git/", ".DS_Store")
def _mime_for(name: str) -> str:
import mimetypes
return mimetypes.guess_type(name)[0] or "application/octet-stream"
@register_importer
class ObsidianImporter(Importer):
source_id = "obsidian"
label = "Obsidian (vault .zip)"
description = "Vault Obsidian : notes Markdown, frontmatter YAML, wikilinks, pièces jointes."
extensions = (".zip",)
order = 10
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".zip"):
return False
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = zf.namelist()
if any("/.obsidian/" in n or n.startswith(".obsidian/") for n in names):
return True
# Heuristic: mostly-markdown archive containing wikilinks.
md = [n for n in names if n.lower().endswith(".md")]
if not md:
return False
for n in md[:20]:
try:
if "[[" in decode_text(zf.read(n)):
return True
except Exception: # noqa: BLE001
continue
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
notes = [n for n in names if n.lower().endswith(".md")]
assets = [n for n in names if not n.lower().endswith(".md")]
for name in assets:
clean = name.replace("\\", "/")
if any(part in clean for part in _SKIP_DIRS) or clean.split("/")[-1].startswith("."):
continue
try:
payload = zf.read(name)
except Exception: # noqa: BLE001
continue
result.attachments.append(ImportAttachment(
source_path=name,
filename=clean.rsplit("/", 1)[-1],
data=payload,
mime=_mime_for(name),
))
for name in sorted(notes, key=lambda n: (n.count("/"), n.lower())):
clean = name.replace("\\", "/")
if any(part in clean for part in _SKIP_DIRS):
continue
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
meta, body = split_frontmatter(text)
body = convert_wikilinks(body)
parts = clean.split("/")
title = normalize_title(meta.get("title")) or parts[-1][:-3]
props = dict(meta)
props.pop("title", None)
tags = coerce_tags(meta.get("tags"))
if tags:
props["tags"] = tags
result.pages.append(ImportPage(
title=title or "Untitled",
markdown=body,
source_path=clean,
parent_path="/".join(parts[:-1]),
properties=props,
external_id=clean,
))
return result.finalize()
-86
View File
@@ -1,86 +0,0 @@
"""FlowDeck — OPML importer (v5.6.0, Phase 4).
Imports an OPML outline (RSS readers, feed lists) as a collection of feeds.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Any
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Feed URL", "type": "url"},
{"name": "Site URL", "type": "url"},
{"name": "Type", "type": "select", "options": [
{"name": "rss", "color": "orange"},
{"name": "folder", "color": "gray"},
]},
{"name": "Folder", "type": "text"},
]
@register_importer
class OpmlImporter(Importer):
source_id = "opml"
label = "OPML (flux RSS)"
description = "Outline OPML → collection de flux (titre, URL, dossier)."
extensions = (".opml", ".xml")
order = 34
def detect(self, filename: str, data: bytes) -> bool:
if filename.lower().endswith(".opml"):
return True
head = decode_text(data)[:1000].lower()
return "<opml" in head and "<outline" in head
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
rows: list[dict] = []
try:
root = ET.fromstring(decode_text(data))
except ET.ParseError as exc:
result.warn(f"OPML invalide : {exc}")
return result.finalize()
for outline in root.iter("outline"):
attrs = {k.lower(): v for k, v in outline.attrib.items()}
feed = attrs.get("xmlurl")
title = attrs.get("title") or attrs.get("text") or feed or ""
if not feed and not title:
continue
props: dict[str, Any] = {}
if feed:
props["Feed URL"] = feed
props["Type"] = "rss"
else:
props["Type"] = "folder"
if attrs.get("htmlurl"):
props["Site URL"] = attrs["htmlurl"]
folder = _folder_of(outline, root)
if folder:
props["Folder"] = folder
rows.append({"title": title[:200] or "Feed", "properties": props})
result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
def _folder_of(node: ET.Element, root: ET.Element) -> str:
parents = {child: parent for parent in root.iter() for child in parent}
parts: list[str] = []
current = parents.get(node)
while current is not None:
attrs = {k.lower(): v for k, v in current.attrib.items()}
if not attrs.get("xmlurl"):
label = attrs.get("title") or attrs.get("text")
if label:
parts.append(label)
current = parents.get(current)
return " / ".join(reversed(parts))
-164
View File
@@ -1,164 +0,0 @@
"""FlowDeck — Logseq / Roam Research outliner importer (v5.6.0, Phase 1)."""
from __future__ import annotations
import io
import re
import zipfile
from app.services.importers._common import (
coerce_tags,
convert_wikilinks,
normalize_title,
split_frontmatter,
)
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_LOGSEQ_MARKERS = ("property::", "logseq/", "journals/")
_ROAM_MARKERS = ("{{[[TODO]]}}", "{{[[DONE]]}}", "{{[[query]]}}")
_PROP_RE = re.compile(r"^\s*([a-zA-Z][\w-]*)::\s*(.*)$")
def _journal_title(name: str) -> str:
m = re.match(r"^(\d{4})[_-](\d{2})[_-](\d{2})", name)
if m:
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
return name
def _clean_outline(text: str) -> tuple[dict, str]:
meta, body = split_frontmatter(text)
lines_out: list[str] = []
for line in body.splitlines():
m = _PROP_RE.match(line)
if m and line.lstrip().startswith("-"):
continue
# Logseq properties appear as bare ``key:: value`` lines too.
m2 = _PROP_RE.match(line)
if m2 and not line.lstrip().startswith(("-", "*", "#", "|")):
key = m2.group(1)
if key not in meta:
meta[key] = m2.group(2).strip()
continue
lines_out.append(line)
body = "\n".join(lines_out)
# Roam task markers → GFM checkboxes.
body = body.replace("{{[[TODO]]}}", "[ ] ").replace("{{[[DONE]]}}", "[x] ")
# Block references ((uuid)) → plain anchors.
body = re.sub(r"\(\(([0-9a-fA-F-]{6,})\)\)", r"[[\1]]", body)
body = convert_wikilinks(body)
return meta, body
class _OutlineBase(Importer):
markers: tuple[str, ...] = ()
property_syntax = False
source_id = "outline"
label = "Outliner"
description = ""
order = 30
def _text_matches(self, text: str) -> bool:
if any(m in text for m in self.markers if not m.endswith("/")):
return True
return bool(self.property_syntax and re.search(r"^\s*[a-zA-Z][\w-]*::", text, re.M))
def _looks_like(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith((".md", ".markdown", ".txt")):
return self._text_matches(decode_text(data))
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = zf.namelist()
if any(m in name for m in self.markers if m.endswith("/") for name in names):
return True
for n in [x for x in names if x.lower().endswith(".md")][:10]:
try:
if self._text_matches(decode_text(zf.read(n))):
return True
except Exception: # noqa: BLE001
continue
return False
def detect(self, filename: str, data: bytes) -> bool:
return self._looks_like(filename, data)
def _emit(self, result: ImportResult, name: str, text: str, is_journal: bool) -> None:
meta, body = _clean_outline(text)
parts = name.replace("\\", "/").split("/")
raw_title = parts[-1].rsplit(".", 1)[0]
title = normalize_title(meta.get("title")) or (
_journal_title(raw_title) if is_journal else raw_title
)
props = {k: v for k, v in meta.items() if k != "title"}
tags = coerce_tags(meta.get("tags"))
if tags:
props["tags"] = tags
result.pages.append(ImportPage(
title=title or "Untitled",
markdown=body,
source_path=name,
parent_path="/".join(parts[:-1]),
properties=props,
external_id=name,
))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
for name in [n for n in names if not n.lower().endswith(".md")]:
try:
result.attachments.append(ImportAttachment(
source_path=name,
filename=name.replace("\\", "/").rsplit("/", 1)[-1],
data=zf.read(name),
))
except Exception: # noqa: BLE001
continue
for name in sorted(
(n for n in names if n.lower().endswith(".md")),
key=lambda n: (n.count("/"), n.lower()),
):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
self._emit(result, name, text, is_journal="journal" in name.lower())
return result.finalize()
text = decode_text(data)
self._emit(result, filename, text, is_journal=False)
return result.finalize()
@register_importer
class LogseqImporter(_OutlineBase):
source_id = "logseq"
label = "Logseq"
description = "Outliner Logseq : pages/journal, propriétés `key:: value`, block refs."
markers = ("property::", "logseq/", "journals/")
property_syntax = True
@register_importer
class RoamImporter(_OutlineBase):
source_id = "roam"
label = "Roam Research"
description = "Outliner Roam : `{{[[TODO]]}}`, block refs, wikilinks."
markers = _ROAM_MARKERS
-94
View File
@@ -1,94 +0,0 @@
"""FlowDeck — PDF importer (v5.6.0, Phase 3).
Best-effort text + image extraction from a PDF into a FlowDeck page (fidelity
depends on the source PDF; scanned documents have no text layer).
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
register_importer,
)
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".tiff": "image/tiff", ".tif": "image/tiff"}
@register_importer
class PdfImporter(Importer):
source_id = "pdf"
label = "PDF"
description = "Extraction texte + images d'un PDF (fidélité limitée)."
extensions = (".pdf",)
order = 37
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith(".pdf")
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from pypdf import PdfReader
except ImportError:
result.warn("pypdf n'est pas installé : import PDF indisponible")
return result.finalize()
try:
reader = PdfReader(io.BytesIO(data))
except Exception as exc: # noqa: BLE001
result.warn(f"PDF illisible : {exc}")
return result.finalize()
chunks: list[str] = []
empty_pages = 0
for index, page in enumerate(reader.pages, start=1):
try:
text = (page.extract_text() or "").strip()
except Exception: # noqa: BLE001
text = ""
if text:
if len(reader.pages) > 1:
chunks.append(f"## Page {index}\n\n{text}")
else:
chunks.append(text)
else:
empty_pages += 1
chunks.extend(self._page_images(page, index, result))
if empty_pages:
result.warn(f"{empty_pages} page(s) sans couche texte (document scanné ?)")
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(chunks)).strip()
title = Path(filename).stem or "Document"
result.pages.append(ImportPage(
title=title, markdown=markdown, source_path=filename, external_id=filename,
))
return result.finalize()
def _page_images(self, page, index: int, result: ImportResult) -> list[str]:
images: list[str] = []
try:
page_images = list(page.images)
except Exception: # noqa: BLE001
return images
for i, image in enumerate(page_images, start=1):
name = getattr(image, "name", "") or f"page{index}_img{i}.png"
name = Path(name).name
try:
payload = image.data
except Exception: # noqa: BLE001
continue
if not payload:
continue
result.attachments.append(ImportAttachment(
source_path=f"page{index}/{name}", filename=name, data=payload,
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
))
images.append(f"![{name}]({name})")
return images
-566
View File
@@ -1,566 +0,0 @@
"""FlowDeck — common import pipeline (v5.6.0, Phase 0).
Persists an :class:`~app.services.importers.base.ImportResult` into FlowDeck:
resolves the workspace, rebuilds the folder hierarchy (``parent_id``), stores
attachments, rewrites links, creates collections + rows, and records imported
items for idempotent re-imports.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from app.db import get_conn
from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks
from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.tabular import apply_type_mapping
logger = logging.getLogger(__name__)
_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _safe_filename(name: str) -> str:
base = Path(name.replace("\\", "/")).name
base = re.sub(r"[^\w.\- ()]+", "_", base).strip() or "file"
return base[:150]
def _sha1(*parts: str) -> str:
return hashlib.sha1("||".join(parts).encode("utf-8")).hexdigest()
def _resolve_workspace(conn, workspace_id: int | None, workspace_name: str | None,
user_login: str) -> tuple[int | None, str]:
if workspace_id:
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if row:
return row["id"], row["name"]
name = workspace_name or user_login
if name:
row = conn.execute("SELECT id, name FROM workspaces WHERE name=?", (name,)).fetchone()
if row:
return row["id"], row["name"]
return workspace_id, name or ""
def _rewrite_links(text: str, attachment_map: dict[str, str]) -> str:
"""Point Markdown links/images at uploaded attachment URLs."""
def repl(m: re.Match) -> str:
alt, target = m.group(1), m.group(2)
url = attachment_map.get(target) or attachment_map.get(Path(target).name.lower())
return f"![{alt}]({url})" if url else m.group(0)
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", repl, text)
return text
def _extract_media(blocks: list[dict]) -> list[dict]:
"""Split inline image markdown out of paragraphs into real image blocks."""
out: list[dict] = []
for block in blocks:
if block.get("type") != "paragraph":
out.append(block)
continue
content = str(block.get("content", ""))
pos = 0
found = False
for match in _IMG_RE.finditer(content):
found = True
before = content[pos:match.start()].strip()
if before:
out.append({"type": "paragraph", "content": before})
out.append({"type": "image", "src": match.group(2), "alt": match.group(1)})
pos = match.end()
if not found:
out.append(block)
continue
tail = content[pos:].strip()
if tail:
out.append({"type": "paragraph", "content": tail})
return out
def _properties_callout(props: dict) -> dict | None:
if not props:
return None
lines = [f"**{k}** : {', '.join(map(str, v)) if isinstance(v, list) else v}" for k, v in props.items()]
return {"type": "callout", "icon": "ℹ️", "content": "\n".join(lines)}
def _blocks_for(page: ImportPage, attachment_map: dict[str, str], *, include_properties: bool) -> list[dict]:
if page.blocks:
return _extract_media(page.blocks)
md = _rewrite_links(page.markdown or "", attachment_map)
blocks = _extract_media(markdown_to_blocks(md))
if include_properties and page.properties:
callout = _properties_callout(page.properties)
if callout:
blocks.insert(0, callout)
return blocks
def preview_result(result: ImportResult) -> dict[str, Any]:
"""Dry-run preview: what would be created, without touching the database."""
pages = []
for page in result.pages:
if page.collection:
kind = "collection"
rows = len(page.collection.get("rows", []))
blocks = len(_blocks_for(page, {}, include_properties=False))
schema = page.collection.get("schema", [])
else:
kind = "page"
rows = 0
blocks = len(_blocks_for(page, {}, include_properties=False))
schema = []
pages.append({
"title": page.title,
"type": kind,
"source_path": page.source_path,
"parent_path": page.parent_path,
"properties": list(page.properties.keys()),
"rows": rows,
"blocks": blocks,
"schema": schema,
})
return {
"source": result.source,
"dry_run": True,
"pages": pages,
"stats": {
**result.stats,
"pages": len(result.pages),
"collections": sum(1 for p in result.pages if p.collection),
"attachments": len(result.attachments),
"warnings": len(result.warnings),
},
"warnings": result.warnings,
}
def _insert_page(conn, *, workspace: str, workspace_id: int | None, title: str,
blocks: list[dict], parent_id: int | None, sort_order: int,
content_format: str = "blocks") -> int:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, "
"sort_order, workspace_id, parent_id) VALUES (?,?,?,?,'Private',?,?,?)",
(workspace, title or "Untitled", json.dumps(blocks), content_format,
sort_order, workspace_id, parent_id),
)
return cur.lastrowid
def _prop_id_map(conn, collection_id: int) -> dict[str, int]:
return {
r["name"]: r["id"]
for r in conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
def _rows_to_values(rows: list[dict], id_map: dict[str, int]) -> list[tuple[str, dict]]:
"""Key row properties by property id (the shape the editor reads)."""
out: list[tuple[str, dict]] = []
for row in rows:
values: dict[str, Any] = {}
for name, value in (row.get("properties") or {}).items():
prop_id = id_map.get(name)
if prop_id is not None:
values[str(prop_id)] = value
out.append((row.get("title") or "Untitled", values))
return out
def _ensure_default_view(conn, collection_id: int) -> None:
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) "
"VALUES (?,?,?,?)",
(collection_id, "Default View", "table",
json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
)
def _insert_collection(conn, page: ImportPage, *, workspace_id: int | None,
parent_page_id: int | None) -> tuple[int, int]:
spec = page.collection or {}
schema = spec.get("schema", [])
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json, is_inline, "
"parent_page_id, workspace_id) VALUES (?,?,?,?,1,?,?)",
(page.title or spec.get("name") or "Imported database", "", "📥",
json.dumps(schema), parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
_ensure_default_view(conn, collection_id)
id_map = _prop_id_map(conn, collection_id)
position = 0
for title, values in _rows_to_values(spec.get("rows", []), id_map):
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, title, position, json.dumps(values)),
)
position += 1
return collection_id, position
def _upsert_collection_rows(conn, collection_id: int, rows: list[dict]) -> tuple[int, int]:
"""Update existing rows by title, insert the new ones. Returns (created, updated)."""
id_map = _prop_id_map(conn, collection_id)
existing = {
(r["title"] or "").strip(): r["id"]
for r in conn.execute(
"SELECT id, title FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchall()
}
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
created = updated = 0
for title, values in _rows_to_values(rows, id_map):
pid = existing.get((title or "").strip())
if pid:
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), pid),
)
updated += 1
else:
max_pos += 1
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, title, max_pos, json.dumps(values)),
)
created += 1
return created, updated
def _update_page(conn, page_id: int, title: str, blocks: list[dict]) -> None:
conn.execute(
"UPDATE pages SET title=?, content=?, content_format='blocks', "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(title or "Untitled", json.dumps(blocks), page_id),
)
def run_import(
result: ImportResult,
*,
workspace_id: int | None = None,
workspace_name: str | None = None,
user_login: str = "",
parent_page_id: int | None = None,
target_collection_id: int | None = None,
dry_run: bool = False,
dedup: bool = True,
include_properties: bool = True,
mapping: dict[str, str] | None = None,
mode: str | None = None,
) -> dict[str, Any]:
"""Persist an import result. Returns a report dict.
``mode`` controls re-import behaviour for items already imported (matched by
``import_items``): ``skip`` (default), ``update`` (re-sync in place) or
``duplicate`` (always create a new page).
"""
if dry_run:
return preview_result(result)
if mapping:
for page in result.pages:
if page.collection:
apply_type_mapping(page.collection, mapping)
if not mode:
mode = "skip" if dedup else "duplicate"
report: dict[str, Any] = {
"source": result.source,
"status": "ok",
"mode": mode,
"pages_created": 0,
"pages_updated": 0,
"collections_created": 0,
"rows_created": 0,
"rows_updated": 0,
"attachments": 0,
"skipped": 0,
"page_ids": [],
"errors": [],
"warnings": list(result.warnings),
}
with get_conn() as conn:
ws_id, ws_name = _resolve_workspace(conn, workspace_id, workspace_name, user_login)
if not ws_name:
report["status"] = "error"
report["warnings"].append("Workspace introuvable")
return report
attachment_map: dict[str, str] = {}
if result.attachments and ws_id:
dest_dir = _data_dir() / "uploads" / f"workspace_{ws_id}" / "import"
dest_dir.mkdir(parents=True, exist_ok=True)
for att in result.attachments:
safe = _safe_filename(att.filename)
target = dest_dir / safe
if target.exists():
target = dest_dir / f"{_sha1(att.source_path)[:8]}_{safe}"
try:
target.write_bytes(att.data)
except OSError:
continue
url = f"/api/files/{ws_id}/import/{target.name}"
attachment_map[att.source_path] = url
attachment_map[att.source_path.lower()] = url
attachment_map[Path(att.source_path).name.lower()] = url
report["attachments"] += 1
if target_collection_id:
return _import_into_collection(
conn, result, target_collection_id, report, attachment_map,
dedup=dedup, workspace_id=ws_id, include_properties=include_properties,
)
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_name,),
).fetchone()[0]
order_counter = [next_order]
path_to_page: dict[str, int] = {}
folder_cache: dict[str, int | None] = {}
def ensure_folder(path: str, _depth: int = 0) -> int | None:
path = (path or "").strip("/")
if not path:
return parent_page_id
if path in folder_cache:
return folder_cache[path]
parent_path = "/".join(path.split("/")[:-1])
parent_id = ensure_folder(parent_path, _depth + 1)
title = path.split("/")[-1] or "Folder"
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=title,
blocks=[], parent_id=parent_id, sort_order=order_counter[0],
)
order_counter[0] += 1
folder_cache[path] = pid
path_to_page[path] = pid
report["pages_created"] += 1
report["page_ids"].append(pid)
return pid
ordered = sorted(
result.pages,
key=lambda p: (p.parent_path.count("/") if p.parent_path else -1, p.source_path.lower()),
)
for page in ordered:
external = page.external_id or page.source_path or page.title
existing_pid = None
if external:
row = conn.execute(
"SELECT page_id FROM import_items WHERE workspace_id IS ? AND source=? AND external_id=?",
(ws_id, result.source, external),
).fetchone()
if row:
existing_pid = row["page_id"]
if existing_pid and mode == "skip":
report["skipped"] += 1
continue
try:
page_parent = ensure_folder(page.parent_path) if page.parent_path else parent_page_id
blocks = _blocks_for(page, attachment_map, include_properties=include_properties)
if existing_pid and mode == "update":
if page.collection:
cid = _collection_for_page(conn, existing_pid)
if cid:
materialize_properties(conn, cid, (page.collection or {}).get("schema", []))
created, updated = _upsert_collection_rows(
conn, cid, (page.collection or {}).get("rows", []))
report["rows_created"] += created
report["rows_updated"] += updated
blocks = blocks + [{"type": "embed", "embed_type": "collection",
"collection_id": cid, "content": ""}]
_update_page(conn, existing_pid, page.title, blocks)
report["pages_updated"] += 1
report["page_ids"].append(existing_pid)
path_to_page[page.source_path] = existing_pid
continue
if page.collection:
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
)
order_counter[0] += 1
cid, rows = _insert_collection(conn, page, workspace_id=ws_id, parent_page_id=pid)
conn.execute(
"UPDATE pages SET content=? WHERE id=?",
(json.dumps(blocks + [{"type": "embed", "embed_type": "collection",
"collection_id": cid, "content": ""}]), pid),
)
report["collections_created"] += 1
report["rows_created"] += rows
report["pages_created"] += 1
report["page_ids"].append(pid)
else:
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
)
order_counter[0] += 1
report["pages_created"] += 1
report["page_ids"].append(pid)
path_to_page[page.source_path] = pid
if external:
conn.execute(
"INSERT OR IGNORE INTO import_items (workspace_id, source, external_id, page_id) VALUES (?,?,?,?)",
(ws_id, result.source, external, pid),
)
except Exception as exc: # noqa: BLE001 - partial import must keep going
logger.warning("import failed for %r: %s", page.title, exc)
report["errors"].append({"title": page.title, "error": str(exc)})
conn.commit()
if report["errors"]:
report["status"] = "partial"
return report
def _collection_for_page(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT id FROM collections WHERE parent_page_id=? ORDER BY id LIMIT 1", (page_id,)
).fetchone()
return row["id"] if row else None
def _import_into_collection(conn, result: ImportResult, collection_id: int, report: dict,
attachment_map: dict[str, str], *, dedup: bool,
workspace_id: int | None, include_properties: bool) -> dict:
exists = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not exists:
report["status"] = "error"
report["warnings"].append("Collection cible introuvable")
return report
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
id_map = _prop_id_map(conn, collection_id)
for page in result.pages:
external = page.external_id or page.source_path or page.title
if dedup and external:
row = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? AND title=?",
(collection_id, page.title),
).fetchone()
if row:
report["skipped"] += 1
continue
props = {
str(id_map[name]): value
for name, value in page.properties.items()
if name in id_map
}
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, page.title, max_pos, json.dumps(props)),
)
max_pos += 1
report["rows_created"] += 1
conn.commit()
return report
def resolve_relations(conn, workspace_id: int | None) -> dict[str, Any]:
"""Convert text columns that reference another imported collection's titles
into real ``relation`` properties (array of ``collection_pages`` ids)."""
collections = conn.execute(
"SELECT id, name FROM collections WHERE workspace_id IS ?", (workspace_id,)
).fetchall()
if not collections:
return {"relations_resolved": 0, "details": []}
titles: dict[int, dict[str, int]] = {}
for coll in collections:
rows = conn.execute(
"SELECT id, title FROM collection_pages WHERE collection_id=?", (coll["id"],)
).fetchall()
titles[coll["id"]] = {
(r["title"] or "").strip(): r["id"]
for r in rows if (r["title"] or "").strip()
}
resolved = 0
details: list[dict] = []
for coll in collections:
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
(coll["id"],),
).fetchall()
pages = conn.execute(
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
(coll["id"],),
).fetchall()
for prop in props:
if prop["prop_type"] not in ("text", "select", "multi_select"):
continue
key = str(prop["id"])
values: list[str] = []
for page in pages:
pv = json.loads(page["property_values_json"] or "{}")
value = pv.get(key)
if value in (None, "", []):
continue
items = value if isinstance(value, list) else [value]
values.extend(str(v) for v in items)
if not values:
continue
for target in collections:
if target["id"] == coll["id"]:
continue
target_titles = titles.get(target["id"]) or {}
if target_titles and all(v in target_titles for v in values):
conn.execute(
"UPDATE collection_properties SET prop_type='relation', "
"related_collection_id=? WHERE id=?",
(target["id"], prop["id"]),
)
for page in pages:
pv = json.loads(page["property_values_json"] or "{}")
value = pv.get(key)
if value in (None, "", []):
continue
items = value if isinstance(value, list) else [value]
pv[key] = [target_titles[str(v)] for v in items if str(v) in target_titles]
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(pv), page["id"]),
)
resolved += 1
details.append({
"collection": coll["name"], "property": prop["name"],
"related": target["name"],
})
break
conn.commit()
return {"relations_resolved": resolved, "details": details}
-96
View File
@@ -1,96 +0,0 @@
"""FlowDeck — Standard Notes importer (v5.6.0, Phase 4).
Imports a Standard Notes backup (``.json``): each non-encrypted note becomes a
FlowDeck page. Encrypted notes are reported as warnings.
"""
from __future__ import annotations
import json
from typing import Any
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_NOTE_TYPES = ("note", "org.standardnotes.sn", "org.standardnotes.plain-text")
def _note_body(content: Any) -> tuple[str, str, bool]:
"""Return (title, markdown, encrypted)."""
if isinstance(content, dict):
if content.get("encrypted"):
return "", "", True
text = content.get("text") or content.get("preview_plain") or ""
title = content.get("title") or ""
return title, text, False
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith("{"):
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict) and ("encrypted" in parsed or "000" in parsed):
return "", "", True
if isinstance(parsed, dict):
return parsed.get("title", ""), parsed.get("text", "") or parsed.get("preview_plain", ""), False
except json.JSONDecodeError:
pass
return "", content, False
return "", "", False
@register_importer
class StandardNotesImporter(Importer):
source_id = "standard_notes"
label = "Standard Notes"
description = "Sauvegarde JSON Standard Notes → pages (notes chiffrées ignorées)."
extensions = (".json",)
order = 39
def _items(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, dict) and isinstance(obj.get("items"), list):
return [x for x in obj["items"] if isinstance(x, dict)]
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
items = self._items(data)
if not items:
return False
return any("content_type" in it for it in items)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
items = self._items(data) or []
count = 0
for item in items:
if item.get("deleted"):
continue
ctype = str(item.get("content_type", "")).lower()
if ctype and not any(t in ctype for t in _NOTE_TYPES):
continue
title, body, encrypted = _note_body(item.get("content"))
if encrypted:
result.warn("Note chiffrée ignorée (déchiffrement non pris en charge)")
continue
if not body.strip():
continue
if not title:
title = next((ln.strip(" #") for ln in body.splitlines() if ln.strip()), "Note")
result.pages.append(ImportPage(
title=title[:200] or "Note",
markdown=body,
source_path=item.get("uuid") or filename,
external_id=item.get("uuid") or f"{filename}#{count}",
))
count += 1
result.stats["rows"] = count
return result.finalize()
-305
View File
@@ -1,305 +0,0 @@
"""FlowDeck — tabular importers: typed CSV/TSV, Excel, generic JSON (v5.6.0, Phase 2).
Each source becomes a FlowDeck collection (database): columns are inferred from
the data (text/number/date/checkbox/email/url/select/multi_select) and rows are
inserted as ``collection_pages``.
"""
from __future__ import annotations
import csv
import io
import json
import re
from typing import Any
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_TITLE_HEADERS = ("title", "name", "task", "nom", "titre", "subject", "label")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_URL_RE = re.compile(r"^https?://\S+$", re.IGNORECASE)
_BOOL_TRUE = {"true", "yes", "oui", "1", "x", "vrai"}
_BOOL_FALSE = {"false", "no", "non", "0", "faux", ""}
def _is_number(value: str) -> bool:
try:
float(str(value).replace(",", ".").replace(" ", ""))
return True
except (ValueError, TypeError):
return False
def _is_bool(value: str) -> bool:
return str(value).strip().lower() in _BOOL_TRUE | _BOOL_FALSE
def infer_column_type(values: list[str]) -> str:
"""Infer the FlowDeck property type from a list of raw string cells."""
sample = [str(v).strip() for v in values if str(v).strip()]
if not sample:
return "text"
if all(_is_bool(v) for v in sample):
return "checkbox"
if all(_is_number(v) for v in sample):
return "number"
if all(_DATE_RE.match(v) for v in sample):
return "date"
if all(_EMAIL_RE.match(v) for v in sample):
return "email"
if all(_URL_RE.match(v) for v in sample):
return "url"
unique = {v for v in sample}
if len(unique) <= 20 and len(unique) <= max(2, len(sample) // 2):
if any(("," in v or ";" in v) for v in sample):
return "multi_select"
return "select"
return "text"
def _split_multi(value: str) -> list[str]:
return [p.strip() for p in re.split(r"[;,]", value) if p.strip()]
def coerce_value(prop_type: str, value: Any) -> Any:
if value is None:
return None
raw = str(value).strip()
if raw == "":
return None
if prop_type == "number":
try:
num = float(raw.replace(",", ".").replace(" ", ""))
return int(num) if num.is_integer() else num
except (ValueError, TypeError):
return raw
if prop_type == "checkbox":
return raw.lower() in _BOOL_TRUE
if prop_type == "multi_select":
return _split_multi(raw)
return raw
def build_schema(headers: list[str], rows: list[dict[str, Any]]) -> tuple[list[dict], str]:
"""Return ``(schema, title_header)`` from headers + row dicts."""
title_header = ""
for h in headers:
if h and h.strip().lower() in _TITLE_HEADERS:
title_header = h
break
schema: list[dict] = []
for h in headers:
if not h or h == title_header:
continue
ptype = infer_column_type([r.get(h, "") for r in rows])
entry: dict[str, Any] = {"name": h, "type": ptype}
if ptype in ("select", "status", "multi_select"):
seen: list[str] = []
for r in rows:
vals = _split_multi(str(r.get(h, ""))) if ptype == "multi_select" else [str(r.get(h, "")).strip()]
for v in vals:
if v and v not in seen:
seen.append(v)
entry["options"] = [{"name": v, "color": "gray"} for v in seen[:100]]
schema.append(entry)
if title_header:
schema.insert(0, {"name": title_header, "type": "title"})
return schema, title_header
def rows_to_collection(name: str, headers: list[str], raw_rows: list[dict[str, Any]]) -> ImportPage:
"""Normalize parsed rows into an ImportPage carrying a collection spec."""
schema, title_header = build_schema(headers, raw_rows)
rows = _normalize_rows(headers, raw_rows, schema, title_header)
return ImportPage(
title=name or "Imported database",
collection={
"name": name or "Imported database",
"schema": schema,
"rows": rows,
"headers": headers,
"title_header": title_header,
"raw_rows": raw_rows,
},
source_path=name,
external_id=name,
)
def _normalize_rows(headers: list[str], raw_rows: list[dict[str, Any]],
schema: list[dict], title_header: str) -> list[dict[str, Any]]:
types = {s["name"]: s["type"] for s in schema}
rows: list[dict[str, Any]] = []
for raw in raw_rows:
title = ""
if title_header:
title = str(raw.get(title_header, "")).strip()
if not title:
for h in headers:
if h and str(raw.get(h, "")).strip():
title = str(raw[h]).strip()
break
props: dict[str, Any] = {}
for h in headers:
if not h or h == title_header:
continue
val = coerce_value(types.get(h, "text"), raw.get(h))
if val is not None and val != "":
props[h] = val
rows.append({"title": title or "Untitled", "properties": props})
return rows
def apply_type_mapping(spec: dict, mapping: dict[str, str]) -> dict:
"""Override inferred column types (UI mapping) and re-coerce the rows."""
if not mapping:
return spec
for entry in spec.get("schema", []):
if entry.get("name") in mapping:
entry["type"] = mapping[entry["name"]]
headers = spec.get("headers")
raw_rows = spec.get("raw_rows")
if headers is not None and raw_rows is not None:
spec["rows"] = _normalize_rows(headers, raw_rows, spec.get("schema", []),
spec.get("title_header", ""))
return spec
def _sniff_delimiter(sample: str) -> str:
try:
return csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
except csv.Error:
return "\t" if sample.count("\t") > sample.count(",") else ","
@register_importer
class CsvImporter(Importer):
source_id = "csv"
label = "CSV / TSV (typé)"
description = "Tableur CSV/TSV : types inférés automatiquement, une collection par fichier."
extensions = (".csv", ".tsv")
order = 60
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith((".csv", ".tsv"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
if not text.strip():
result.warn("Fichier vide")
return result.finalize()
delimiter = "\t" if filename.lower().endswith(".tsv") else _sniff_delimiter(text[:4096])
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
headers = [h for h in (reader.fieldnames or []) if h is not None]
rows = [dict(r) for r in reader]
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
result.pages.append(rows_to_collection(name, headers, rows))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ExcelImporter(Importer):
source_id = "excel"
label = "Excel (.xlsx)"
description = "Classeur Excel : une collection par feuille (openpyxl)."
extensions = (".xlsx", ".xlsm")
order = 61
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith((".xlsx", ".xlsm")):
return True
return low.endswith(".xls")
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from openpyxl import load_workbook
except ImportError:
result.warn("openpyxl n'est pas installé : import Excel indisponible")
return result.finalize()
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception as exc: # noqa: BLE001
result.warn(f"Classeur illisible : {exc}")
return result.finalize()
base = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
total_rows = 0
for ws in wb.worksheets:
values = list(ws.iter_rows(values_only=True))
if not values:
continue
headers = [str(h).strip() if h is not None else f"Column {i + 1}" for i, h in enumerate(values[0])]
rows: list[dict[str, Any]] = []
for row in values[1:]:
if row is None or all(c is None or str(c).strip() == "" for c in row):
continue
rows.append({headers[i]: row[i] for i in range(min(len(headers), len(row)))})
if not rows:
continue
name = f"{base} — {ws.title}" if len(wb.worksheets) > 1 else (base or ws.title)
page = rows_to_collection(name, headers, rows)
page.source_path = f"{filename}#{ws.title}"
page.external_id = page.source_path
result.pages.append(page)
total_rows += len(rows)
result.stats["rows"] = total_rows
return result.finalize()
@register_importer
class JsonImporter(Importer):
source_id = "json"
label = "JSON (mapping générique)"
description = "Tableau d'objets JSON → collection (union des clés)."
extensions = (".json",)
order = 65
def _records(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
return obj
if isinstance(obj, dict):
for value in obj.values():
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
return value
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
return self._records(data) is not None
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
records = self._records(data)
if not records:
result.warn("Aucun tableau d'objets JSON détecté")
return result.finalize()
headers: list[str] = []
for rec in records:
for key in rec:
if key not in headers:
headers.append(key)
flat: list[dict[str, Any]] = []
for rec in records:
row = {}
for h in headers:
v = rec.get(h)
row[h] = json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v
flat.append(row)
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
result.pages.append(rows_to_collection(name, headers, flat))
result.stats["rows"] = len(flat)
return result.finalize()
-94
View File
@@ -1,94 +0,0 @@
"""FlowDeck — URL / web clipper importer (v5.6.0, Phase 5).
Fetches a web page and turns it into a page: a bookmark card (OG metadata)
followed by the article converted to FlowDeck blocks.
"""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
from app.services.export import markdown_to_blocks
from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain"}
_MAX_BYTES = 3_000_000
def _is_public_host(host: str) -> bool:
"""SSRF guard: reject loopback/private/link-local/reserved addresses."""
if not host or host.lower() in _BLOCKED_HOSTS:
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return False
for info in infos:
address = info[4][0]
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_url(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
raise ValueError("Seules les URLs http(s) sont autorisées")
if not parsed.hostname or not _is_public_host(parsed.hostname):
raise ValueError("Hôte non autorisé")
return url.strip()
def _bookmark_block(url: str, meta: dict) -> dict:
block = {"type": "bookmark", "url": url}
for key in ("title", "description", "image", "site_name"):
if meta.get(key):
block[key] = meta[key]
return block
async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None = None) -> ImportResult:
"""Fetch ``url`` and build a single-page ImportResult (raises on bad URL)."""
safe_url = _validate_url(url)
result = ImportResult(source="url")
try:
async with httpx.AsyncClient(
timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client:
response = await client.get(safe_url)
response.raise_for_status()
if response.url.host and not _is_public_host(response.url.host):
raise ValueError("Redirection vers un hôte non autorisé")
content_type = response.headers.get("content-type", "")
if "html" not in content_type.lower():
raise ValueError("La ressource n'est pas une page HTML")
body = response.text[:_MAX_BYTES]
except httpx.HTTPError as exc:
result.warn(f"Échec du téléchargement : {exc}")
return result.finalize()
from app.services.og_fetcher import parse_og
meta = parse_og(body, safe_url)
title = (meta.get("title") or urlparse(safe_url).hostname or "Page").strip()
markdown = _html_to_markdown(body)
blocks = [_bookmark_block(safe_url, meta)]
if markdown:
blocks.extend(markdown_to_blocks(markdown))
result.pages.append(ImportPage(
title=title[:200],
blocks=blocks,
source_path=safe_url,
external_id=safe_url,
))
return result.finalize()
-590
View File
@@ -1,590 +0,0 @@
"""FlowDeck — LLM client abstraction (v4.10.0).
Abstraction over multiple LLM providers so the agent never talks to the DB
directly — it emits *tool intentions* (function calls) that AgentEngine turns
into guarded internal actions.
Supported providers (OpenAI-compatible chat-completions JSON response):
openai, anthropic, mistral, cohere, google, groq, deepseek, openrouter,
nvidia, together, perplexity, xai, qwencloud, minimax, morph, fireworks,
cerebras, sambanova, chutes, xiaomi, sealion, sensenova,
ollama (local, no key). Anthropic, Google (`/v1beta/openai`) and Cohere
(`/compatibility/v1`) expose an OpenAI-compatible surface at their base URL.
When no API key is configured (or provider == "offline") the client falls back
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
functional — and fully testable — with zero external calls, which is what the
local deployment and the test-suite rely on.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Provider → default model + base URL when llm_model/api_base are empty.
# All entries speak the OpenAI-compatible chat-completions protocol (Anthropic,
# Google and Cohere expose an OpenAI-compatible surface at their given base).
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
"perplexity": ("https://api.perplexity.ai", "sonar-pro"),
"xai": ("https://api.x.ai/v1", "grok-4.6"),
"qwencloud": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-max"),
"minimax": ("https://api.minimax.chat/v1", "MiniMax-Text-01"),
"morph": ("https://api.morphllm.com/v1", "morph-v3-large"),
"fireworks": ("https://api.fireworks.ai/inference/v1",
"accounts/fireworks/models/deepseek-v4-pro-0813"),
"cerebras": ("https://api.cerebras.ai/v1", "llama-3.3-70b"),
"sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
"chutes": ("https://llm.chutes.ai/v1", "deepseek-ai/DeepSeek-V3"),
"xiaomi": ("https://api.xiaomimimo.com/v1", "mimo-7b-rl"),
"sealion": ("https://api.sea-lion.ai/v1", "aisingapore/Llama-SEA-LION-v3-70B-IT"),
"sensenova": ("https://api.sensenova.cn/compatible-mode/v1", "SenseChat-5"),
"ollama": ("http://localhost:11434/v1", "llama3.1"),
"offline": (None, None),
}
# Friendly display names for the Settings / Agent UIs.
PROVIDER_LABELS: dict[str, str] = {
"openai": "OpenAI",
"anthropic": "Anthropic",
"mistral": "Mistral",
"cohere": "Cohere",
"google": "Google Gemini",
"groq": "Groq",
"deepseek": "DeepSeek",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"together": "Together AI",
"perplexity": "Perplexity",
"xai": "xAI (Grok)",
"qwencloud": "DashScope (Alibaba)",
"minimax": "MiniMax",
"morph": "Morph",
"fireworks": "Fireworks AI",
"cerebras": "Cerebras",
"sambanova": "SambaNova",
"chutes": "Chutes AI",
"xiaomi": "Xiaomi (MiMo)",
"sealion": "SEA-LION",
"sensenova": "SenseNova",
"ollama": "Ollama (local)",
"offline": "Hors-ligne (mock)",
}
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
"mixtral-8x7b-32768", "gemma2-9b-it"],
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
"openai/gpt-4o", "mistralai/mistral-large"],
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
"together": ["meta-llama/Llama-3.3-70B-Instruct-Turbo",
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
"Qwen/Qwen2.5-72B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
"perplexity": ["sonar-pro", "sonar", "sonar-reasoning", "sonar-deep-research"],
"xai": ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning",
"grok-build-0.1"],
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
"minimax": ["MiniMax-Text-01", "abab6.5s-chat", "abab6.5-chat"],
"morph": ["morph-v3-large", "morph-v3-fast"],
"fireworks": ["accounts/fireworks/models/deepseek-v4-pro-0813",
"accounts/fireworks/models/kimi-k2p6",
"accounts/fireworks/models/glm-5p2",
"accounts/fireworks/models/minimax-m3",
"accounts/fireworks/models/gpt-oss-120b",
"accounts/fireworks/models/qwen3-8b"],
"cerebras": ["llama-3.3-70b", "llama3.1-8b", "llama-3.1-70b"],
"sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-405B-Instruct",
"Qwen2.5-72B-Instruct"],
"chutes": ["deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1",
"Qwen/Qwen2.5-72B-Instruct"],
"xiaomi": ["mimo-7b-rl", "mimo-7b"],
"sealion": ["aisingapore/Llama-SEA-LION-v3-70B-IT",
"aisingapore/Gemma-SEA-LION-v3-9B-IT"],
"sensenova": ["SenseChat-5", "SenseChat-5-Cantonese", "SenseChat-Turbo"],
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
"offline": [],
}
# Llama-style / ChatML tool markers used by the mock planner.
_CREATE_PATTERNS = [
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_page", {"title": m.group(1).strip()})),
]
_SEARCH_PATTERNS = [
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
]
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
# "créer la collection X", "create collection X", etc.)
_COLLECTION_LINE = re.compile(
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
re.IGNORECASE,
)
@dataclass
class LLMResponse:
"""Normalized completion: either a final text or one or more tool calls."""
text: str = ""
tool_calls: list[dict] = field(default_factory=list)
model: str = ""
usage: dict = field(default_factory=dict)
notice: str = ""
class LLMClient:
"""Multi-provider chat client with tool-calling support and offline mock."""
def __init__(self, provider: str | None = None, api_key: str | None = None,
api_base: str | None = None):
from .llm_config import get_llm_config # local import avoids a cycle
cfg = get_llm_config()
self.provider = (provider or cfg["provider"] or "offline").lower()
self.api_key = api_key if api_key is not None else cfg["api_key"]
self.api_base = api_base if api_base is not None else cfg["api_base"]
base, model = PROVIDERS.get(self.provider, (None, None))
self.api_base = self.api_base or base
# Le modèle global configuré n'est valable que pour le provider global :
# tester un autre provider (ex. nvidia alors que deepseek est actif) ne doit
# PAS lui envoyer le modèle du provider actif (sinon « model not found »).
cfg_provider = (cfg.get("provider") or "offline").lower()
global_model = (cfg.get("model") or "") if self.provider == cfg_provider else ""
self.default_model = global_model or model or "gpt-4o"
# ── Public API ──
async def complete(self, messages: list[dict], *, model: str | None = None,
tools: list[dict] | None = None,
stream: bool = False) -> LLMResponse:
"""Send a chat completion. Returns text and/or tool_calls."""
model = model or self.default_model
if self.provider == "offline" or not self._has_credentials():
return await self._mock_complete(messages, model, tools)
try:
return await asyncio.wait_for(
self._http_complete(messages, model, tools),
timeout=settings.agent_run_timeout_seconds,
)
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
# l'utilisateur voie pourquoi rien n'a été généré.
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
raise
async def is_available(self) -> bool:
"""True when a real provider is configured."""
return self.provider != "offline" and self._has_credentials()
async def ping(self, *, model: str | None = None) -> LLMResponse:
"""Reach the provider without mock fallback (used by the "Test connection"
UI). Raises on any real error so the caller can surface it."""
model = model or self.default_model
if self.provider == "offline":
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
if not self._has_credentials():
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
return await asyncio.wait_for(
self._http_complete(
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
model,
None,
),
timeout=settings.agent_run_timeout_seconds,
)
# ── Helpers ──
def _has_credentials(self) -> bool:
if self.provider == "ollama":
return True # local, no key required
return bool(self.api_key)
def _endpoint(self) -> str:
return f"{self.api_base.rstrip('/')}/chat/completions"
@staticmethod
def _http_error_detail(exc: httpx.HTTPStatusError) -> str:
"""Human-readable HTTP error including the provider's response body.
Providers return actionable JSON on 4xx (e.g. « model not allowed »,
« country not supported »); surfacing it makes the Settings test
debuggable instead of a bare « 403 Forbidden ».
"""
resp = exc.response
try:
body = (resp.text or "").strip()
except Exception: # noqa: BLE001 — body already consumed / undecodable
body = ""
if len(body) > 500:
body = body[:500] + "…"
base = f"{resp.status_code} {resp.reason_phrase} ({resp.url})"
return f"{base}: {body}" if body else base
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
payload: dict = {
"model": model,
"messages": messages,
"temperature": 0.2,
}
if tools:
payload["tools"] = [{"type": "function", "function": t} for t in tools]
payload["tool_choice"] = "auto"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
# Repli robuste : le modèle choisi a été retiré / n'existe plus
# (404 « model not found » / 410 « has reached its end of life »).
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
# provider et on signale le basculement — la liste validée peut avoir
# vieilli (modèle déprécié entre deux rafraîchissements).
if exc.response.status_code in (404, 410) \
and model and self.default_model and model != self.default_model:
logger.warning(
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
model, exc.response.status_code, self.provider, self.default_model,
)
return await self._http_complete(messages, self.default_model, tools, _noticer=(
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
f"Réponse générée avec « {self.default_model} » à la place."
))
# Surface the provider's own error body (403 « forbidden », 400 …).
raise RuntimeError(self._http_error_detail(exc)) from exc
response = self._parse_response(data, model)
response.notice = _noticer or ""
return response
def _parse_response(self, data: dict, model: str) -> LLMResponse:
choice = data["choices"][0]["message"]
text = choice.get("content") or ""
tool_calls = []
for tc in choice.get("tool_calls") or []:
fn = tc.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
tool_calls.append({
"id": tc.get("id") or "",
"name": fn.get("name"),
"arguments": args,
"arguments_raw": fn.get("arguments") or "",
})
return LLMResponse(
text=text,
tool_calls=tool_calls,
model=model,
usage=data.get("usage", {}),
)
# ── Offline mock planner (deterministic, no network) ──
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
user_content = self._last_user_content(messages)
sys_content = self._system_content(messages)
# Only the user's objective drives the planner. The engine appends the
# workspace/document snapshot under "# Contexte"; that text must never
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
# used to misroute content requests into tool calls).
objective = user_content.split("\n# Contexte")[0]
# Once tool results are already in the conversation, we have acted:
# stop issuing new tool calls and conclude.
if any(m.get("role") == "tool" for m in messages):
return LLMResponse(
text="Objectif traité — actions enregistrées dans le journal d'audit.",
model=model,
)
# Skill-driven: if the objective names a known skill, mirror its template.
skill_hint = self._extract_skill_hint(objective)
if skill_hint == "sprint":
return LLMResponse(
tool_calls=[
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
{"name": "create_collection", "arguments": {"name": "Sprint"}},
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
],
text="Plan: analyze open issues, then build a sprint board.",
model=model,
)
# Inline content-generation ("Ask AI" / "AI meeting note") — answered
# before the tool-intent heuristics and scoped to the user objective
# only, so an injected "# Contexte" that happens to mention "collection"
# can't misroute a writing request into a create-collection action.
draft = self._draft_reply(objective)
if draft:
return LLMResponse(text=draft, model=model)
# Documents / espaces de travail (offline): unambiguous intents resolved
# from the objective — create a document (optionally in a named
# workspace) or list the accessible workspaces.
doc_args = self._document_create_args(objective)
if doc_args is not None:
return LLMResponse(
tool_calls=[{"name": "create_document", "arguments": doc_args}],
text="Plan: création d'un document.",
model=model,
)
if self._is_workspaces_request(objective):
return LLMResponse(
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
text="Plan: lister les espaces de travail.",
model=model,
)
# Exact keyword → tool intent resolution (objective only).
for regex, builder in _CREATE_PATTERNS:
m = regex.search(objective)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
text=f"Plan: running {builder(m)[0]}.",
model=model,
)
for regex, builder in _SEARCH_PATTERNS:
m = regex.search(objective)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
text=f"Plan: searching '{m.group(1)}'.",
model=model,
)
# Loose "collection <X>" detection → treat as a create intent.
low = objective.lower()
if "collection" in low:
m = _COLLECTION_LINE.search(objective)
if m:
name = m.group(1).strip()
return LLMResponse(
tool_calls=[{"name": "create_collection",
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
text=f"Plan: create collection '{name}'.",
model=model,
)
# Plain conversational objective → final answer (no tool).
return LLMResponse(
text=self._summarize(objective),
model=model,
)
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
"""Inject a collection id from the context when the planner left it as 0."""
args = dict(args)
if args.get("collection_id") == 0:
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
if match:
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
if ids:
args["collection_id"] = ids[0]
return args
@staticmethod
def _document_create_args(content: str) -> dict | None:
"""Deterministic `create_document` intent for the offline mock.
Only fires when the user clearly asks to *create* a document (a content
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
Returns None when the message is not a create-document intent.
"""
low = content.lower()
if "document" not in low:
return None
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
"nouveau document", "nouvelle page", "faire un")):
return None
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
title = quotes[0].strip() if quotes else None
if not title:
m = re.search(
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
content, re.IGNORECASE,
)
if m:
title = m.group(1).strip()
if not title:
return None
args = {"title": title}
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
args["workspace_name"] = quotes[-1].strip()
return args
@staticmethod
def _is_workspaces_request(content: str) -> bool:
"""True when the user asks to list / locate the workspaces."""
low = content.lower()
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
"affiche", "mes espaces", "ou sont", "où sont"))
return has_ws and has_verb
@staticmethod
def _system_content(messages) -> str:
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
@staticmethod
def _last_user_content(messages) -> str:
for m in reversed(messages):
if m.get("role") == "user":
c = m.get("content", "")
if isinstance(c, list):
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
return str(c)
return ""
@staticmethod
def _extract_skill_hint(content: str) -> str | None:
low = content.lower()
if "sprint" in low or "préparation de sprint" in low:
return "sprint"
return None
@staticmethod
def _summarize(content: str) -> str:
"""Produce a terse final summary from a conversational objective."""
content = content.split("\n# Contexte")[0]
return (content[:600] + "…" if len(content) > 600 else content)
def _draft_reply(self, content: str) -> str | None:
"""Generate usable structured copy for content/meeting requests when no
real LLM is configured (offline mock). Returns None when the message is
not a clear content-generation intent so other paths keep their behavior.
"""
from datetime import date
low = content.lower()
title = None
m = re.search(r"intitul[ée]e\s*[«\"']([^»\"']+)[»\"']", content)
if m:
title = m.group(1).strip()
today = date.today().isoformat()
# ── AI meeting note template ──
if any(k in low for k in ("ai meeting note", "meeting note",
"compte-rendu", "compte rendu",
"notes de réunion", "réunion")):
return (
"📅 AI Meeting Note\n"
f"Date : {today} · Participants : (à renseigner)\n"
"\n"
"## Résumé\n"
"Point central de la discussion et contexte (à compléter).\n"
"\n"
"## Décisions\n"
"• Décision 1 — valider le périmètre et les responsables.\n"
"• Décision 2 — définir la prochaine échéance.\n"
"\n"
"## Action items\n"
"☐ Action 1 — responsable : …, échéance : …\n"
"☐ Action 2 — responsable : …, échéance : …\n"
"\n"
"## Prochaines étapes\n"
"• Planifier le suivi et archiver ce compte-rendu.\n"
)
# ── Traduction / analyse du document (hors-ligne) ──
if re.search(r"traduis|traduit|translate", low):
return (
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
"connecté).\n\n"
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
"à approuver ou à rejeter avant application."
)
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
return (
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
"document.\n\n"
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
"ou à rejeter avant application."
)
# ── Page / document draft (contextual "Ask AI") ──
if title:
t = title[:80]
return (
f"{t}\n"
"\n"
f"Présentation générale du sujet « {t} » : objectif, contexte et "
"public visé en quelques phrases. (Document généré hors-ligne — "
"connectez une clé API pour une rédaction complète.)\n"
"\n"
"## Objectif\n"
"• Clarifier le besoin couvert par ce document.\n"
"• Lister les livrables attendus.\n"
"\n"
"## Points clés\n"
"• Idée principale 1 avec les arguments associés.\n"
"• Idée principale 2 et les exemples concrets.\n"
"\n"
"## Prochaines étapes\n"
"• Relire, compléter et mettre en forme ce contenu.\n"
)
# ── Generic drafting verb, no title (typing directly in the chat) ──
if re.search(r"^(r[ée]dige|[ée]cris|[ée]crire|g[ée]n[èe]re|produis|d[ée]veloppe|"
r"[ée]cris\s+un|g[ée]n[èe]re\s+un|r[ée]dige\s+un)\b", low):
return (
"## Introduction\n"
"Contexte et objectif de ce texte, en une à deux phrases.\n"
"\n"
"## Développement\n"
"• Premier argument structuré avec un exemple.\n"
"• Deuxième argument appuyé par une donnée ou un fait.\n"
"\n"
"## Conclusion\n"
"Synthèse et prochaine étape recommandée.\n"
)
return None
-444
View File
@@ -1,444 +0,0 @@
"""FlowDeck — Runtime LLM configuration store (v4.10.2).
Precedence (per conversation):
1. explicit `provider`/`model` passed to the run endpoint,
2. the user's saved key for that provider (`user_llm_keys`),
3. the global `llm_config` row (id=1) — admin UI,
4. `settings.llm_*` (.env), default « offline mock ».
Rows are created lazily, so .env stays the default until saved from the UI.
"""
from __future__ import annotations
import json
import time
from app.config import settings
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions
# actually serves. The fetched list is validated (name filter + live probe)
# before being exposed as "usable models". NVIDIA exposes all of its catalog
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
# chat completions — the exact failure the user hit.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
_NON_CHAT_MARKERS = (
"embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
"transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
"stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
"ocr", "inpainting", "depth", "motion", "sento-",
)
def _is_likely_chat(model_id: str) -> bool:
ml = model_id.lower()
return not any(m in ml for m in _NON_CHAT_MARKERS)
__all__ = [
"get_llm_config", "set_llm_config", "provider_info",
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
"delete_user_llm_key", "fetch_provider_models",
"mark_llm_config_verified", "mark_user_llm_key_verified",
]
def get_llm_config() -> dict:
"""Return the effective LLM config — DB overrides .env when present."""
cfg = {
"provider": settings.llm_provider or "offline",
"model": settings.llm_model or "gpt-4o",
"api_key": settings.llm_api_key or "",
"api_base": settings.llm_api_base or "",
"verified": 0,
"verified_model": "",
"verified_at": "",
"last_error": "",
}
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT provider, model, api_key, api_base, verified, verified_model, "
"verified_at, last_error FROM llm_config WHERE id=1"
).fetchone()
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
return cfg
if row:
for key in ("provider", "model", "api_key", "api_base"):
if row[key]:
cfg[key] = row[key]
if row["provider"]:
cfg["verified"] = row["verified"] or 0
cfg["verified_model"] = row["verified_model"] or ""
cfg["verified_at"] = row["verified_at"] or ""
cfg["last_error"] = row["last_error"] or ""
return cfg
def set_llm_config(*, provider: str | None = None, model: str | None = None,
api_key: str | None = None, api_base: str | None = None,
clear_keys: bool = False) -> dict:
"""Upsert the runtime LLM config row (id=1) and return the new effective config.
An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak
the model/base without re-typing the key). Changing the API key resets the
`verified` flag — the provider has to pass a connection test again.
"""
from app.db import get_conn
cfg = get_llm_config()
if provider is not None:
cfg["provider"] = provider
if model is not None:
cfg["model"] = model
if api_key is not None and api_key.strip():
key_changed = cfg.get("api_key") != api_key.strip()
cfg["api_key"] = api_key.strip()
if key_changed:
cfg["verified"] = 0
cfg["verified_model"] = ""
cfg["last_error"] = ""
if api_base is not None:
# Normalise so a base equal to the provider default is stored as "use
# default" — a later correction of PROVIDERS then applies automatically.
cfg["api_base"] = _normalize_api_base(
provider or cfg.get("provider") or "offline", api_base
)
if clear_keys:
cfg["api_key"] = ""
cfg["api_base"] = ""
cfg["verified"] = 0
cfg["verified_model"] = ""
cfg["last_error"] = ""
with get_conn() as conn:
conn.execute(
"""INSERT INTO llm_config (id, provider, model, api_key, api_base,
verified, verified_model, last_error, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
provider=excluded.provider, model=excluded.model,
api_key=excluded.api_key, api_base=excluded.api_base,
verified=excluded.verified, verified_model=excluded.verified_model,
last_error=excluded.last_error,
updated_at=CURRENT_TIMESTAMP""",
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"],
cfg.get("verified", 0), cfg.get("verified_model", ""),
cfg.get("last_error", "")),
)
conn.commit()
return cfg
def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None:
"""Record the outcome of the admin 'Test connection' for the global default."""
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone()
provider = row["provider"] if row else (settings.llm_provider or "offline")
conn.execute(
"""INSERT INTO llm_config (id, provider, model, verified, verified_model,
verified_at, last_error, updated_at)
VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
verified=excluded.verified,
verified_model=excluded.verified_model,
verified_at=excluded.verified_at,
last_error=excluded.last_error,
updated_at=CURRENT_TIMESTAMP""",
(provider, 1 if ok else 0, model if ok else "",
1 if ok else 0, "" if ok else error),
)
conn.commit()
def provider_info() -> list[dict]:
"""Providers list for the UI: known models + whether an API key is required.
``base_url`` is the endpoint the application uses by default for this
provider's chat requests (shown in the Settings "URL API" fields).
"""
out: list[dict] = []
for name, (base, default_model) in PROVIDERS.items():
models = list(PROVIDER_MODELS.get(name) or ())
if default_model and default_model not in models:
models.insert(0, default_model)
out.append({
"id": name,
"name": PROVIDER_LABELS.get(name) or name.replace("_", " ").title(),
"default_model": default_model,
"models": models,
"base_url": (base or ""),
"requires_key": name not in ("offline", "ollama"),
})
return out
# ── Per-user provider keys ──
def _mask_key(row) -> dict:
"""Public view of a stored key row: never exposes the raw api_key."""
def _get(name, default=""):
try:
v = row[name]
return default if v is None else v
except (KeyError, IndexError):
return default
return {
"provider": row["provider"],
"api_base": row["api_base"] or "",
"default_model": row["default_model"] or "",
"models": json.loads(row["models_json"] or "[]"),
"has_key": bool(row["api_key"]),
"verified": bool(_get("verified", 0)),
"verified_model": _get("verified_model") or "",
"last_error": _get("last_error") or "",
}
def get_user_llm_key(user_id: int, provider: str) -> dict | None:
"""Return a stored key row (includes the raw api_key — server-side only)."""
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?",
(user_id, provider),
).fetchone()
return dict(row) if row else None
def list_user_llm_keys(user_id: int) -> list[dict]:
"""Public (masked) list of the user's saved provider keys."""
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider",
(user_id,),
).fetchall()
return [_mask_key(r) for r in rows]
def _default_api_base(provider: str) -> str:
"""The OpenAI-compatible base URL the app uses by default for a provider."""
base = (PROVIDERS.get(provider.lower()) or (None, None))[0]
return (base or "").rstrip("/")
def _normalize_api_base(provider: str, api_base: str) -> str:
"""Drop an api_base that just repeats the provider default.
Storing the default as a per-user override freezes it: a later correction
of the provider URL (e.g. Cohere `/v2` → `/compatibility/v1`) would never
apply. An empty value means "use the provider default".
"""
value = (api_base or "").strip()
if value.rstrip("/") == _default_api_base(provider):
return ""
return value
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
api_base: str | None = None, default_model: str = "",
models: list[str] | None = None) -> dict:
"""Upsert a user's provider key. Empty api_key keeps the existing one
(allows saving model/base without re-typing the key). Saving a *different*
key resets the `verified` flag so the provider must pass a test again.
``api_base`` uses ``None`` to mean "keep the stored value" and an empty
string to explicitly reset it to the provider default.
"""
from app.db import get_conn
provider = provider.lower()
existing = get_user_llm_key(user_id, provider)
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
if api_base is None:
new_base = (existing.get("api_base", "") if existing else "")
else:
new_base = _normalize_api_base(provider, api_base)
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
new_models = models if models is not None else (
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
)
key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key)
# A changed key invalidates the previous verification; keep it otherwise.
verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0
with get_conn() as conn:
conn.execute(
"""INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, provider) DO UPDATE SET
api_key=excluded.api_key, api_base=excluded.api_base,
default_model=excluded.default_model, models_json=excluded.models_json,
verified=excluded.verified,
updated_at=CURRENT_TIMESTAMP""",
(user_id, provider, new_key, new_base, new_model,
json.dumps(new_models, ensure_ascii=False), verified),
)
conn.commit()
return get_user_llm_key(user_id, provider) or {
"provider": provider, "api_key": new_key, "api_base": new_base,
"default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False),
"verified": verified,
}
def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *,
model: str = "", error: str = "") -> None:
"""Record the outcome of a connection test on one of the user's providers."""
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
conn.execute(
"""UPDATE user_llm_keys
SET verified=?, verified_model=?, last_error=?,
verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END,
updated_at=CURRENT_TIMESTAMP
WHERE user_id=? AND provider=?""",
(1 if ok else 0, model if ok else "", "" if ok else error,
1 if ok else 0, user_id, provider),
)
conn.commit()
def delete_user_llm_key(user_id: int, provider: str) -> None:
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
conn.execute(
"DELETE FROM user_llm_keys WHERE user_id=? AND provider=?",
(user_id, provider),
)
conn.commit()
async def fetch_provider_models(provider: str, *, api_key: str = "",
api_base: str = "", timeout: int = 20) -> list[str]:
"""Fetch the live model list from a provider (best-effort, no mock).
OpenAI-compatible providers (including Google's `/openai` surface and
Cohere's compatibility API) use `GET {base}/models` with a Bearer token;
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
Returns a de-duplicated list capped at 300 models.
"""
import httpx
provider = provider.lower()
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
if not base:
return [] # offline — nothing to fetch
base_url = base.rstrip("/")
headers: dict = {}
if provider == "anthropic":
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
elif api_key:
headers = {"Authorization": f"Bearer {api_key}"}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{base_url}/models", headers=headers)
if resp.status_code >= 400:
body = (resp.text or "").strip()
if len(body) > 500:
body = body[:500] + "…"
raise RuntimeError(
f"{resp.status_code} {resp.reason_phrase} ({resp.url}): {body}"
)
data = resp.json()
ids: list[str] = []
for item in data.get("data") or []:
if not isinstance(item, dict):
continue
i = (item.get("id") or "").strip()
if i:
ids.append(i)
for item in data.get("models") or []:
if not isinstance(item, dict):
continue
n = (item.get("name") or item.get("id") or "").strip()
if provider == "google" and n.startswith("models/"):
n = n[len("models/"):]
if n:
ids.append(n)
seen: set[str] = set()
out: list[str] = []
for i in ids:
if i not in seen:
seen.add(i)
out.append(i)
# Providers like NVIDIA list their whole catalog, most of which is NOT served
# by /v1/chat/completions (404 sur « model not found »). Filter by name first,
# then probe the survivors with a minimal chat call so only usable models stay.
if provider in _CHAT_VALIDATED_PROVIDERS and out:
candidates = [m for m in out if _is_likely_chat(m)] or out
validated = await _validate_chat_models(base_url, api_key, candidates)
# Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
# indisponibilité passagère) on garde la liste filtrée par nom.
out = validated if validated else candidates
return out[:300]
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
*, timeout: float = 12.0, concurrency: int = 5,
deadline: float = 90.0, attempts: int = 2) -> list[str]:
"""Probe `POST {base_url}/chat/completions` for each candidate and keep only
the models that genuinely answer — using the exact payload the app sends at
runtime (`temperature: 0.2`, no `max_tokens`).
Hard failures (404 model inconnu, 410 modèle retiré…) exclude the model.
A 429 (rate limit) is kept: it proves the route exists, so the model is
usable once the quota frees up. Transient failures (timeouts, 5xx, network)
are retried once before giving up, so slow-but-working models survive.
"""
import asyncio
import httpx
sem = asyncio.Semaphore(concurrency)
start = time.monotonic()
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = f"{base_url.rstrip('/')}/chat/completions"
payload_tpl = {
"messages": [{"role": "user", "content": "ping"}],
"temperature": 0.2,
}
async def probe(model: str) -> str | None:
if time.monotonic() - start > deadline:
return None
payload: dict = {"model": model, **payload_tpl}
for attempt in range(attempts):
if time.monotonic() - start > deadline:
return None
try:
async with sem:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
code = resp.status_code
if code < 300 or code == 429:
return model
if code < 500: # 400/401/403/404/410… → définitivement non utilisable
return None
# 5xx → passage passager, on retente une fois
except Exception: # noqa: BLE001 — timeouts / connexion → on retente
if attempt >= attempts - 1:
return None
return None
results = await asyncio.gather(*(probe(m) for m in candidates))
return [m for m in results if isinstance(m, str)]
-86
View File
@@ -1,86 +0,0 @@
"""FlowDeck — Email notifications via SMTP (v4.9.0).
If SMTP is not configured (smtp_host empty) this is a safe no-op, so the
application works locally out of the box while still logging intent.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from app.config import settings
logger = logging.getLogger(__name__)
def _configured() -> bool:
return bool(settings.smtp_host)
def _html_body(body_text: str, cta_url: str = "") -> str:
cta = ""
if cta_url:
cta = (
'<p style="margin:24px 0 0;">'
f'<a href="{cta_url}" '
'style="background:#2383E2;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:8px;display:inline-block;'
'font-weight:600;">Open in FlowDeck &rarr;</a></p>'
)
return f"""<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;
background:#191919;color:#e0e0e0;padding:32px;">
<div style="max-width:520px;margin:0 auto;background:#252525;border:1px solid #333;
border-radius:12px;padding:24px;">
<div style="font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">FlowDeck</div>
<p style="color:#e0e0e0;line-height:1.6;white-space:pre-wrap;">{body_text}</p>
{cta}
<p style="margin-top:24px;font-size:12px;color:#999;">You received this because your
notifications preferences in FlowDeck allow it.</p>
</div></div>"""
def send_email(to_email: str, subject: str, body_text: str, cta_url: str = "") -> bool:
"""Send an email. Returns True on success, False if skipped or failed."""
if not to_email or not _configured():
return False
try:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to_email
msg.set_content(body_text)
msg.add_alternative(_html_body(body_text, cta_url), subtype="html")
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
if settings.smtp_use_tls:
server.starttls()
if settings.smtp_user:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
logger.info("Email sent to %s: %s", to_email, subject)
return True
except Exception as e: # never break the request on mail failure
logger.warning("Email send failed to %s: %s", to_email, e)
return False
def notify_user(
user_id: int,
subject: str,
body_text: str,
cta_url: str = "",
prefs_key: str = "mentions",
) -> bool:
"""Resolve a user's email + preferences and send an email notification."""
from app.db import get_conn
from app.services import notifications
with get_conn() as conn:
row = conn.execute("SELECT id, email FROM users WHERE id=?", (user_id,)).fetchone()
if not row or not row["email"]:
return False
prefs = notifications.get_user_prefs(user_id)
if not prefs.get(prefs_key, True):
return False
return send_email(row["email"], subject, body_text, cta_url)
-213
View File
@@ -1,213 +0,0 @@
"""FlowDeck — Notification service (v4.9.0 collaboration).
Creates in-app notifications (mentions, comments, page changes) and triggers
email delivery via :mod:`app.services.mailer` when the target user has opted in.
"""
from __future__ import annotations
import json
import re
from app.db import get_conn
from app.services import mailer
def create_notification(
user_id: int,
actor_id: int | None,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
commit: bool = True,
) -> int | None:
"""Insert a notification row. Returns the new id (or None if skipped).
``conn`` may be supplied to join an existing transaction (caller controls
commit). Otherwise a dedicated connection is opened and committed.
"""
if not user_id:
return None
if conn is not None:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
if commit:
conn.commit()
return cur.lastrowid
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
conn.commit()
return cur.lastrowid
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
def extract_mentions(text: str) -> list[str]:
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
def process_mentions(
text: str,
actor_id: int,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
) -> list[int]:
"""Create notifications for every user @-mentioned in ``text``.
Returns the list of mentioned user ids that were notified.
"""
handles = extract_mentions(text)
if not handles:
return []
notified = []
if conn is not None:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
return notified
with get_conn() as conn:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
conn.commit()
return notified
def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles))
rows = conn.execute(
f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
handles,
).fetchall()
for row in rows:
if row["id"] == actor_id:
continue
create_notification(
row["id"], actor_id, ntype, title, message,
resource_type, resource_id, url, conn=conn, commit=False,
)
notified.append(row["id"])
mailer.notify_user(
row["id"], subject=title, body_text=message, cta_url=url or "",
)
def get_user_prefs(user_id: int, conn=None) -> dict:
if conn is not None:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
else:
with get_conn() as conn:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": True, "reminders": True, "assignments": True}
try:
prefs = json.loads(row["notification_prefs"])
except (TypeError, json.JSONDecodeError):
prefs = {}
return {"comments": bool(prefs.get("comments", True)),
"mentions": bool(prefs.get("mentions", True)),
"reminders": bool(prefs.get("reminders", True)),
"assignments": bool(prefs.get("assignments", True))}
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
if conn is not None:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
else:
with get_conn() as conn:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
return prefs
def _person_ids(value) -> list[int]:
"""Extract user ids from a stored ``person`` property value."""
ids: list[int] = []
if isinstance(value, dict):
value = [value]
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
try:
ids.append(int(person["id"]))
except (TypeError, ValueError):
pass
return ids
def notify_assignment(collection_id: int, page_id: int, page_title: str,
old_props: dict, new_props: dict, actor_id: int | None,
conn=None) -> list[int]:
"""Notify users newly assigned via a ``person`` property.
Compares old vs new property values keyed by property id; users present
in the new value but not the old one get an in-app + (opt-in) email
notification. Returns the notified user ids.
"""
def _run(c):
props = c.execute(
"SELECT id, name FROM collection_properties "
"WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
newly: list[int] = []
for p in props:
key = str(p["id"])
before = set(_person_ids((old_props or {}).get(key)))
after = _person_ids((new_props or {}).get(key))
for uid in after:
if uid not in before and uid != actor_id and uid not in newly:
newly.append(uid)
for uid in newly:
create_notification(
uid, actor_id, "assignment",
title="Assigned to you",
message=page_title or "Untitled",
resource_type="db_page",
resource_id=page_id,
url=f"/collections/{collection_id}",
conn=c, commit=False,
)
mailer.notify_user(
uid, subject="[FlowDeck] Assigned to you",
body_text=page_title or "Untitled",
cta_url=f"/collections/{collection_id}",
prefs_key="assignments",
)
return newly
if conn is not None:
return _run(conn)
with get_conn() as c:
result = _run(c)
c.commit()
return result
-142
View File
@@ -1,142 +0,0 @@
"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx.
Fetches a URL server-side, extracts OG/Twitter meta tags (title, description,
image, site name, favicon) and returns a safe, compact payload used to render
Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and
slow/unreachable hosts.
"""
from __future__ import annotations
import html as htmlmod
import logging
import re
from urllib.parse import urljoin, urlparse
logger = logging.getLogger(__name__)
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", re.I)
_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I)
# Property/name keys we look for, in priority order, mapped to our payload keys.
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
_OG_DESC = ("og:description", "twitter:description", "description")
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image")
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
def _attrs(tag: str) -> dict:
return {k.lower(): v for k, v in _ATTR_RE.findall(tag)}
def _extract_og(body: str) -> dict:
"""Parse all ``<meta>`` tags into a ``{key: content}`` dict.
Attributes may appear in any order (``content`` before or after
``property``/``name``), which the previous implementation mishandled.
First value wins so the most specific tag (top of document) is kept.
"""
props: dict[str, str] = {}
for tag in _META_TAG_RE.finditer(body[:400_000]):
attrs = _attrs(tag.group(0))
key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower()
content = attrs.get("content")
if key and content is not None and key not in props:
props[key] = content
return props
def _pick(props: dict, keys: tuple) -> str:
for k in keys:
v = props.get(k)
if v:
return v
return ""
def _title_of(props: dict, body: str) -> str:
t = _pick(props, _OG_TITLE)
if t:
return t
m = _TITLE_RE.search(body[:200_000])
return m.group(1).strip() if m else ""
def _site_name(url: str) -> str:
host = urlparse(url).netloc.replace("www.", "")
return host.split(".")[0].capitalize() if host else ""
def _favicon(body: str, base_url: str) -> str:
for tag in _FAVICON_RE.finditer(body):
attrs = _attrs(tag.group(0))
rel = attrs.get("rel", "")
href = attrs.get("href", "")
if href and _ICON_REL.search(rel):
return urljoin(base_url, htmlmod.unescape(href))
return ""
def parse_og(body: str, url: str) -> dict:
"""Pure HTML → bookmark payload (no network). ``url`` is the base URL."""
src = url.strip()
if not src.startswith(("http://", "https://")):
src = "https://" + src
props = _extract_og(body)
title = htmlmod.unescape(_title_of(props, body))
desc = htmlmod.unescape(_pick(props, _OG_DESC))
img = _pick(props, _OG_IMG)
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
def abs_url(u: str) -> str:
return urljoin(src, htmlmod.unescape(u)) if u else ""
return {
"url": src,
"title": title.strip()[:200] or urlparse(src).netloc or src,
"description": desc.strip()[:400],
"image": abs_url(img),
"site_name": site.strip()[:100],
"favicon": _favicon(body, src),
}
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors.
``transport`` is an optional ``httpx`` transport (used by tests to mock
HTTP without hitting the network).
"""
src = url.strip()
if not src.startswith(("http://", "https://")):
src = "https://" + src
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
try:
import httpx
headers = {
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
kwargs = {"follow_redirects": True, "timeout": timeout}
if transport is not None:
kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client:
resp = await client.get(src, headers=headers)
resp.raise_for_status()
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
logger.debug("og fetch failed for %s: %s", src, exc)
base["title"] = urlparse(src).netloc or src
base["site_name"] = _site_name(src)
return base
ctype = (resp.headers.get("content-type") or "").lower()
if "text/html" not in ctype and "xhtml" not in ctype:
base["title"] = urlparse(src).netloc or src
base["site_name"] = _site_name(src)
return base
return parse_og(resp.text, src)
-102
View File
@@ -1,102 +0,0 @@
"""FlowDeck — Agent permission guard (v4.10.0).
The agent always acts with *at most* the permissions of the invoking user
(Notion Agent principle). This manager resolves the user's role in the active
workspace and gates tool execution before any write reaches the database.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException
from app.db import get_conn
logger = logging.getLogger(__name__)
# Workspace roles, from least to most privileged.
READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
WRITE_ROLES = {"editor", "admin", "owner"}
DESTRUCTIVE_ROLES = {"admin", "owner"}
# Tools that mutate state and therefore require at least an editor role.
WRITE_TOOLS = {
"create_collection", "create_view", "create_page", "update_page",
"write_blocks", "create_document", "add_property", "add_relation",
"create_sub_item", "add_dependency", "sync_gitea", "create_gitea_issue",
"apply_template",
}
# Tools that delete / are destructive → admin/owner (or confirm mode).
DESTRUCTIVE_TOOLS = {
"delete_page", "delete_collection", "delete_document",
"delete_property", "delete_view",
}
class PermissionManager:
"""Resolves workspace role and gates agent tool calls."""
def __init__(self, user_id: int):
self.user_id = user_id
# ── Role resolution ──
def role_in_workspace(self, workspace_id: int | None) -> str:
"""Return the user's role for a workspace (owner > member role)."""
if workspace_id is None:
# No workspace → fall back to the most permissive own-content model.
return "owner"
with get_conn() as conn:
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, self.user_id),
).fetchone()
if member:
return member["role"] or "editor"
owner = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, self.user_id),
).fetchone()
return "owner" if owner else "viewer"
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES
def can_write(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in WRITE_ROLES
def can_destructive(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in DESTRUCTIVE_ROLES
# ── Gate for the engine ──
def assert_can(self, tool: str, args: dict, workspace_id: int | None,
approval_mode: str = "auto") -> None:
"""Raise HTTPException if the tool call exceeds the user's permissions.
- read tools: any authenticated user in the workspace (viewer+).
- write tools: editor+.
- destructive tools: admin/owner, or requires confirm approval mode.
"""
role = self.role_in_workspace(workspace_id)
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
)
if tool in DESTRUCTIVE_TOOLS:
if role not in DESTRUCTIVE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' is destructive and requires admin/owner "
f"(user is '{role}')",
)
if approval_mode != "confirm":
raise HTTPException(
status_code=428, # Precondition Required
detail=f"Destructive tool '{tool}' requires approval (confirm mode)",
)
# A viewer can always read; editor can read+write.
if role not in READ_ROLES:
raise HTTPException(status_code=403, detail="User has no access to this workspace")
-135
View File
@@ -1,135 +0,0 @@
"""FlowDeck — v5.2.0 Projects: normalized forge-agnostic project registry.
The ``projects`` table stores one row per repository across forges (builtin /
gitea / github). A background scheduler refreshes metadata (default branch,
language) periodically so the UI always shows up-to-date info.
"""
from __future__ import annotations
import logging
from app.config import settings
from app.db import get_conn
from app.services.forge_adapter import GiteaAdapter, normalize_repo
logger = logging.getLogger(__name__)
def register_repo(repo: dict, proj_type: str) -> int | None:
"""Upsert a forge repo into the projects table. Returns project id."""
data = normalize_repo(repo, proj_type)
if not data["name"]:
return None
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type=? AND owner=? AND name=?",
(proj_type, data["owner"], data["name"]),
).fetchone()
if existing:
conn.execute(
"""UPDATE projects SET forge_id=?, clone_url=?, default_branch=?,
language=?, description=?, last_synced_at=CURRENT_TIMESTAMP
WHERE id=?""",
(data["forge_id"], data["clone_url"], data["default_branch"],
data["language"], data["description"], existing["id"]),
)
conn.commit()
return existing["id"]
cur = conn.execute(
"""INSERT INTO projects
(name, proj_type, owner, forge_id, clone_url, default_branch, language, description, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(data["name"], proj_type, data["owner"], data["forge_id"], data["clone_url"],
data["default_branch"], data["language"], data["description"]),
)
conn.commit()
return cur.lastrowid
def list_projects(proj_type: str | None = None) -> list[dict]:
with get_conn() as conn:
if proj_type:
rows = conn.execute(
"SELECT * FROM projects WHERE proj_type=? ORDER BY name",
(proj_type,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
def create_builtin_project(name: str, owner: str = "", description: str = "") -> dict:
"""Register a standalone (non-forge) project."""
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type='builtin' AND owner=? AND name=?",
(owner, name),
).fetchone()
if existing:
return {"id": existing["id"], "name": name}
cur = conn.execute(
"INSERT INTO projects (name, proj_type, owner, description) VALUES (?, 'builtin', ?, ?)",
(name, owner, description),
)
conn.commit()
return {"id": cur.lastrowid, "name": name}
# ═══════════ Periodic sync ═══════════
async def _sync_gitea(user_id: int, token: str) -> int:
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token)
adapter = GiteaAdapter(gitea)
repos = await adapter.list_repos(page=1)
count = 0
for repo in repos:
if register_repo(repo, "gitea"):
count += 1
return count
async def _sync_github(user_id: int, token: str) -> int:
from app.services.github_adapter import GitHubAdapter
adapter = GitHubAdapter(token)
repos = await adapter.list_all_repos()
count = 0
for repo in repos:
if register_repo(repo, "github"):
count += 1
return count
async def sync_all_projects() -> dict:
"""Refresh the projects table from every connected forge token."""
stats = {"gitea": 0, "github": 0, "error": 0}
with get_conn() as conn:
rows = conn.execute(
"SELECT user_id, provider, access_token FROM user_oauth_tokens "
"WHERE provider IN ('gitea','github') AND access_token != ''"
).fetchall()
tokens = [dict(r) for r in rows]
for t in tokens:
try:
if t["provider"] == "gitea":
stats["gitea"] += await _sync_gitea(t["user_id"], t["access_token"])
elif t["provider"] == "github":
stats["github"] += await _sync_github(t["user_id"], t["access_token"])
except Exception as exc:
stats["error"] += 1
logger.warning("project sync (%s user=%s) failed: %s", t["provider"], t["user_id"], exc)
logger.info("projects sync done: %s", stats)
return stats
async def project_sync_scheduler():
"""Background loop: refresh projects every interval (default hourly)."""
while True:
if settings.project_sync_enabled:
try:
await sync_all_projects()
except Exception as exc:
logger.warning("project_sync_scheduler error: %s", exc)
await __import__("asyncio").sleep(settings.project_sync_interval_hours * 3600)
+8 -135
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from datetime import datetime, timezone
from typing import Any, Optional
# ── Property type definitions ──
@@ -106,7 +106,7 @@ SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
@@ -136,9 +136,6 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
@@ -152,141 +149,17 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
return True, ""
def parse_validation(validation) -> dict:
"""Normalize a property's ``validation_json`` into a config dict."""
if validation is None:
return {}
if isinstance(validation, dict):
cfg = dict(validation)
else:
try:
cfg = json.loads(validation) if validation else {}
except (json.JSONDecodeError, TypeError):
cfg = {}
return {
"required": bool(cfg.get("required", False)),
"unique": bool(cfg.get("unique", False)),
"min": cfg.get("min"),
"max": cfg.get("max"),
"min_length": cfg.get("min_length"),
"max_length": cfg.get("max_length"),
}
def validate_property_rule(
prop_type: str,
value: Any,
validation: dict | None = None,
*,
existing_values: list | None = None,
) -> tuple[bool, str]:
"""Validate a property value against type + validation rules.
Returns ``(ok, error_message)``. ``existing_values`` is used to enforce the
``unique`` rule (a list of the values already stored for that property).
"""
cfg = parse_validation(validation)
# Type-level validation first.
ok, msg = validate_property_value(prop_type, value)
if not ok:
return False, msg
# Empty / required
is_empty = value is None or value == "" or (isinstance(value, list) and len(value) == 0)
if is_empty:
if cfg.get("required"):
return False, "This property is required"
return True, ""
# Length bounds (string types)
if isinstance(value, str):
if cfg.get("min_length") is not None and len(value) < int(cfg["min_length"]):
return False, f"Must be at least {int(cfg['min_length'])} characters"
if cfg.get("max_length") is not None and len(value) > int(cfg["max_length"]):
return False, f"Must be at most {int(cfg['max_length'])} characters"
# Numeric bounds
if prop_type == "number" or (isinstance(value, (int, float)) and not isinstance(value, bool)):
try:
num = float(value)
except (ValueError, TypeError):
num = None
if num is not None:
if cfg.get("min") is not None and num < float(cfg["min"]):
return False, f"Must be greater than or equal to {cfg['min']}"
if cfg.get("max") is not None and num > float(cfg["max"]):
return False, f"Must be less than or equal to {cfg['max']}"
# Unique
if cfg.get("unique") and existing_values is not None:
norm = str(value).strip().lower()
for ev in existing_values:
if ev is None:
continue
if str(ev).strip().lower() == norm:
return False, "Value already exists (must be unique)"
return True, ""
def user_ref(user: dict | None) -> dict | None:
"""Normalize a session user dict into the stored ``person`` value shape."""
if not user:
return None
return {
"id": user.get("id"),
"login": user.get("login") or user.get("full_name") or "",
"full_name": user.get("full_name") or "",
"avatar_url": user.get("avatar_url") or "",
"avatar_color": user.get("avatar_color") or "#3A3A3A",
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
if user:
return {"id": user.get("id"), "login": user.get("login")}
return None
return None
def apply_auto_properties(
properties: list[dict],
values: dict,
user: dict | None = None,
*,
is_create: bool = False,
now: str | None = None,
) -> dict:
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
``created_*`` are only written on creation (or when missing); ``last_edited_*``
are refreshed on every call. Returns the mutated dict.
"""
if values is None:
values = {}
stamp = now or datetime.now(UTC).isoformat()
for prop in properties or []:
ptype = prop.get("prop_type")
if ptype not in AUTO_TYPES:
continue
pid = str(prop.get("id"))
if ptype == "created_time":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = stamp
elif ptype == "last_edited_time":
values[pid] = stamp
elif ptype == "created_by":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = user_ref(user)
elif ptype == "last_edited_by":
values[pid] = user_ref(user)
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
-336
View File
@@ -1,336 +0,0 @@
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
merge des opérations de blocs (last-write-wins par bloc) + version de page.
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
avec debounce. Fallback polling côté client si le WS est indisponible.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import WebSocket
from app.db import get_conn
logger = logging.getLogger(__name__)
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
def color_for(uid: int) -> str:
return COLORS[(uid or 0) % len(COLORS)]
def block_id() -> str:
import uuid
return "b" + uuid.uuid4().hex[:12]
def ensure_block_ids(blocks: list[dict]) -> list[dict]:
"""Assign unique ids to blocks missing one, recursively.
Template-created pages may have been persisted without block ids; without
them the room state is not addressable by ops and the editor ends up with
``data-bid="undefined"`` blocks (duplicated / reordered lines).
"""
if not isinstance(blocks, list):
return blocks
for b in blocks:
if isinstance(b, dict):
if not b.get("id"):
b["id"] = block_id()
if isinstance(b.get("children"), list):
ensure_block_ids(b["children"])
return blocks
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
t = op.get("type")
if t == "insert":
blk = op.get("block") or {}
if not blk.get("id"):
blk = dict(blk)
blk["id"] = block_id()
ensure_block_ids([blk])
idx = op.get("index")
if not isinstance(idx, int):
idx = len(blocks)
idx = max(0, min(idx, len(blocks)))
return blocks[:idx] + [blk] + blocks[idx:]
if t == "update":
nb = op.get("block") or {}
if not nb.get("id"):
return blocks
return [nb if b.get("id") == nb["id"] else b for b in blocks]
if t == "delete":
bid = op.get("id")
return [b for b in blocks if b.get("id") != bid]
if t == "move":
bid = op.get("id")
idx = op.get("index", 0) or 0
out = [b for b in blocks if b.get("id") != bid]
idx = max(0, min(idx, len(out)))
moved = next((b for b in blocks if b.get("id") == bid), None)
if moved is None:
return blocks
out.insert(idx, moved)
return out
return blocks
def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
"""Apply a batch of ops sequentially (arrival order)."""
out = blocks
for op in ops or []:
out = apply_op(out, op)
return out
class Room:
__slots__ = ("page_id", "blocks", "title", "version", "conns",
"persist_task", "dirty")
def __init__(self, page_id: int):
self.page_id = page_id
self.blocks: list[dict] = []
self.title = ""
self.version = 0
self.conns: set[RTConn] = set()
self.persist_task: asyncio.Task | None = None
self.dirty = False
class RTConn:
__slots__ = ("ws", "user", "page_id")
def __init__(self, ws: WebSocket, user: dict, page_id: int):
self.ws = ws
self.user = user
self.page_id = page_id
class RealtimeManager:
def __init__(self):
self._rooms: dict[int, Room] = {}
def room(self, page_id: int) -> Room:
return self._rooms.setdefault(page_id, Room(page_id))
@staticmethod
def _peer(user: dict) -> dict:
uid = user.get("id") or 0
return {
"id": uid,
"login": user.get("login", ""),
"full_name": user.get("full_name", "") or user.get("login", ""),
"color": color_for(uid),
}
async def load_room(self, room: Room) -> bool:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
(room.page_id,),
).fetchone()
except Exception as e:
logger.warning("realtime load failed: %s", e)
return False
if not row:
return False
room.title = row["title"] or ""
if (row["content_format"] or "") == "blocks" and row["content"]:
try:
room.blocks = ensure_block_ids(json.loads(row["content"]))
except Exception:
room.blocks = []
return True
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
room = self.room(page_id)
if not room.conns and not await self.load_room(room):
await ws.close(code=4404)
return None
conn = RTConn(ws, user, page_id)
room.conns.add(conn)
me = self._peer(user)
peers = [self._peer(c.user) for c in room.conns if c is not conn]
await ws.send_json({"t": "welcome", "self": me,
"peers": peers, "color": me["color"]})
await ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
for c in room.conns:
if c is not conn:
try:
await c.ws.send_json({"t": "peer_join", "peer": me})
except Exception:
pass
return conn
async def disconnect(self, conn: RTConn):
room = self._rooms.get(conn.page_id)
if not room:
return
room.conns.discard(conn)
for c in room.conns:
try:
await c.ws.send_json({"t": "peer_leave",
"id": conn.user.get("id") or 0})
except Exception:
pass
if not room.conns:
await self.flush(room)
self._rooms.pop(conn.page_id, None)
async def flush(self, room: Room):
"""Write current room state to DB (sync, used on idle + disconnect)."""
if room.persist_task and not room.persist_task.done():
room.persist_task.cancel()
await self._persist(room)
async def _persist(self, room: Room):
try:
with get_conn() as conn:
conn.execute(
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(room.blocks, ensure_ascii=False), room.title, room.page_id),
)
conn.commit()
room.dirty = False
except Exception as e:
logger.warning("realtime persist failed: %s", e)
def _schedule_persist(self, room: Room):
if room.persist_task and not room.persist_task.done():
return
room.dirty = True
async def _run():
try:
await asyncio.sleep(1.2)
await self._persist(room)
except asyncio.CancelledError:
pass
room.persist_task = asyncio.create_task(_run())
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
for c in room.conns:
if c is exclude:
continue
try:
await c.ws.send_json(msg)
except Exception:
pass
async def handle(self, conn: RTConn, msg: dict):
room = self._rooms.get(conn.page_id)
if not room:
return
t = msg.get("t")
me = (conn.user.get("id") or 0)
if t == "hello":
try:
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
except Exception:
pass
return
if t == "sync_req":
try:
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
"title": room.title, "version": room.version})
except Exception:
pass
return
if t == "ping":
try:
await conn.ws.send_json({"t": "pong"})
except Exception:
pass
return
if t == "op":
op = msg.get("op") or {}
client_v = msg.get("v", 0)
room.blocks = apply_op(room.blocks, op)
room.version += 1
self._schedule_persist(room)
stale = client_v < room.version - 1
await self._broadcast(room, {"t": "op", "op": op, "from": me,
"v": room.version}, exclude=conn)
try:
await conn.ws.send_json({"t": "ack", "v": room.version,
"stale": stale})
except Exception:
pass
if stale:
try:
await conn.ws.send_json({"t": "sync",
"blocks": room.blocks,
"title": room.title,
"version": room.version})
except Exception:
pass
return
if t == "title":
fmt = (msg.get("title") or "").strip()
if fmt and fmt != room.title:
room.title = fmt
room.version += 1
self._schedule_persist(room)
await self._broadcast(room, {"t": "title", "title": room.title,
"from": me, "v": room.version}, exclude=conn)
try:
await conn.ws.send_json({"t": "ack", "v": room.version,
"stale": False})
except Exception:
pass
return
if t == "sel":
await self._broadcast(room, {"t": "sel", "from": me,
"peer": self._peer(conn.user),
"block": msg.get("block"),
"offset": msg.get("offset", 0)}, exclude=conn)
return
async def room_state(self, page_id: int) -> dict:
room = self._rooms.get(page_id)
if not room.conns and not room.blocks:
await self.load_room(room)
return {"blocks": room.blocks, "title": room.title, "version": room.version}
async def _propagate_synced(self, synced_id: int) -> None:
"""Broadcast a synced-block update to all rooms that reference it."""
from app.db import get_conn as _get_conn
# Find all pages that reference this synced block
pages: list[int] = []
try:
with _get_conn() as conn:
rows = conn.execute(
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
(synced_id,),
).fetchall()
pages = [r["page_id"] for r in rows]
except Exception:
return
for pid in pages:
room = self._rooms.get(pid)
if room and room.conns:
# Re-load the page content from DB to get fresh synced blocks
await self.load_room(room)
await self._broadcast(room, {"t": "synced_update",
"synced_id": synced_id,
"version": room.version})
manager = RealtimeManager()
-273
View File
@@ -1,273 +0,0 @@
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
A recurring row stores its rule in ``property_values_json`` under the special
key ``__recurrence__``::
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
"count": null, "until": null, "byweekday": [0,2,4],
"timezone": "Europe/Paris"} }
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
on the fly for a visible window — occurrences are virtual, never persisted.
All pure-Python (stdlib only) so it is trivially testable.
"""
from __future__ import annotations
import calendar as _cal
import datetime as dt
import logging
logger = logging.getLogger(__name__)
FREQS = ("daily", "weekly", "monthly")
# Monday-first weekday numbering (matches the UI calendar grid).
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
RECURRENCE_KEY = "__recurrence__"
TIMEZONE_KEY = "__timezone__"
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
from zoneinfo import ZoneInfo, available_timezones
def _zone(tz_name: str):
try:
return ZoneInfo(tz_name)
except Exception:
return None
def is_valid_timezone(tz_name: str) -> bool:
if not tz_name:
return True
try:
return tz_name in available_timezones() or tz_name == "UTC"
except Exception:
return False
except Exception: # pragma: no cover - environment without tz database
def _zone(tz_name: str):
return None
def is_valid_timezone(tz_name: str) -> bool:
return True # no tz database → accept anything (zoneinfo absent)
def parse_date(value) -> dt.date | None:
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
s = str(value or "").strip()
if not s:
return None
s = s.replace(" ", "T")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return dt.datetime.strptime(s[:19], fmt).date()
except ValueError:
continue
try:
return dt.date.fromisoformat(s[:10])
except ValueError:
return None
def validate_rule(rule) -> tuple[bool, str]:
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
if rule is None:
return True, ""
if not isinstance(rule, dict):
return False, "Recurrence must be an object"
freq = rule.get("freq")
if freq not in FREQS:
return False, f"freq must be one of {FREQS}"
interval = rule.get("interval", 1)
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
return False, "interval must be an integer between 1 and 365"
count = rule.get("count")
if count is not None:
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
return False, "count must be an integer between 1 and 1000"
until = rule.get("until")
if until not in (None, "") and parse_date(until) is None:
return False, "until must be a YYYY-MM-DD date"
if count is not None and until not in (None, ""):
return False, "count and until are mutually exclusive"
bywd = rule.get("byweekday")
if bywd not in (None, []):
if not isinstance(bywd, list) or any(
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
):
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
if freq != "weekly":
return False, "byweekday is only valid for weekly recurrence"
tz = rule.get("timezone")
if tz not in (None, "") and not is_valid_timezone(tz):
return False, f"unknown timezone '{tz}'"
return True, ""
def _generate_occurrences(base: dt.date, rule: dict):
"""Yield every occurrence date in chronological order, honouring
freq/interval/count/until/byweekday. Infinite rules yield forever —
the caller must bound the consumption (see expand_rule)."""
freq = rule.get("freq", "daily")
interval = max(1, int(rule.get("interval") or 1))
count = rule.get("count")
until = parse_date(rule.get("until")) if rule.get("until") else None
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
produced = 0
def limit_hit(d: dt.date) -> bool:
return (count is not None and produced >= count) or bool(until and d > until)
if freq == "daily":
d = base
while not limit_hit(d):
yield d
produced += 1
d += dt.timedelta(days=interval)
elif freq == "weekly":
# Monday=0 weekday numbering (matches the UI calendar grid).
days = weekdays or [(base.isoweekday() - 1) % 7]
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
w = week0
while True:
for off in days:
d = w + dt.timedelta(days=off)
if d < base or limit_hit(d):
continue
yield d
produced += 1
w += dt.timedelta(weeks=interval)
if limit_hit(w):
return
else: # monthly
i = 0
while True:
d = _nth_month_occurrence(base, i * interval)
if limit_hit(d):
return
yield d
produced += 1
i += 1
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
max_occurrences: int = 500) -> list[str]:
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
window [window_start, window_end] for a rule anchored at ``base_value``.
Occurrences before the window are skipped (but still count against
``count``); the expansion is hard-capped so pathological rules can never
blow up a request.
"""
base = parse_date(base_value)
ok, _err = validate_rule(rule)
if base is None or rule is None or not ok:
return []
out: list[str] = []
scanned = 0
for d in _generate_occurrences(base, rule or {}):
if d > window_end:
break
scanned += 1
if scanned > 200000: # safety valve for old, very long-running rules
break
if d >= window_start:
out.append(d.isoformat())
if len(out) >= max_occurrences:
break
return out
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
month_index = (base.year * 12 + (base.month - 1)) + months_offset
year, month0 = divmod(month_index, 12)
month = month0 + 1
day = min(base.day, _cal.monthrange(year, month)[1])
return dt.date(year, month, day)
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
"""Current time in the given IANA timezone (fallback: UTC)."""
zone = _zone(tz_name) if tz_name else None
if zone is not None:
return dt.datetime.now(zone)
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
hh, mm = 9, 0
if time_str and len(time_str) >= 16:
try:
hh, mm = int(time_str[11:13]), int(time_str[14:16])
except ValueError:
pass
zone = _zone(tz_name) if tz_name else None
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
if zone is not None:
return naive.replace(tzinfo=zone)
return naive.replace(tzinfo=dt.UTC)
def next_occurrences(base_value, rule: dict, tz_name: str | None,
after: dt.datetime, horizon_days: int = 365,
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
"""Upcoming occurrences (date, aware event datetime) strictly on/after
``after`` for a row anchored at ``base_value``. Non-recurring rules
(rule falsy) yield the single base occurrence. Bounded by horizon."""
base = parse_date(base_value)
if base is None:
return []
time_str = str(base_value or "")
start = after.date()
if not rule:
event = _zone_dt(base, time_str, tz_name)
return [(base, event)] if event >= after else []
ok, _ = validate_rule(rule)
if not ok:
return []
out: list[tuple[dt.date, dt.datetime]] = []
window_end = start + dt.timedelta(days=horizon_days)
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
d = dt.date.fromisoformat(iso)
event = _zone_dt(d, time_str, tz_name)
if event >= after:
out.append((d, event))
if len(out) >= max_count:
break
return out
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
COMMON_TIMEZONES = [
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
]
def common_timezones() -> list[str]:
"""Timezone list for pickers: curated common zones + all installed ones
whose region matches, deduplicated and sorted."""
try:
from zoneinfo import available_timezones
all_zones = sorted(available_timezones())
# Merge: keep curated first, then everything else available.
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
return COMMON_TIMEZONES + extra
except Exception:
return list(COMMON_TIMEZONES)
-225
View File
@@ -1,225 +0,0 @@
"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
Reminders live on database rows, next to the recurrence rule, inside
``property_values_json`` under the special key ``__reminder__``::
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
A background loop scans rows with reminders, computes the next occurrence
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
through the ``reminder_log`` table keyed by (page_id, occurrence date).
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
drive it deterministically with an injected ``now``.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
from app.db import get_conn
from app.services.recurrence import (
RECURRENCE_KEY,
TIMEZONE_KEY,
next_occurrences,
parse_date,
)
logger = logging.getLogger(__name__)
REMINDER_KEY = "__reminder__"
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
# Fire late is still useful; ignore events that happened long ago.
FIRE_GRACE_SECONDS = 3600
def parse_lead(reminder) -> int | None:
"""Return the lead in seconds for a reminder dict, or None if disabled."""
if not isinstance(reminder, dict):
return None
unit = reminder.get("unit", "none")
if unit not in _UNITS:
return None
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return None
if value <= 0:
return None
return value * _UNITS[unit]
def lead_human(reminder: dict) -> str:
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return ""
unit = reminder.get("unit", "")
if value == 1 and unit.endswith("s"):
unit = unit[:-1]
return f"{value} {unit}"
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
"""User ids assigned via any ``person`` property of the row."""
targets: list[int] = []
person_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
for pp in person_props:
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
targets.append(int(person["id"]))
return targets
def _row_tz(conn, collection_id: int, props: dict) -> str:
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
recurrence rule tz, then the created_by user's personal timezone,
then '' (UTC)."""
tzmap = props.get(TIMEZONE_KEY)
if isinstance(tzmap, dict):
for value in tzmap.values():
if value:
return str(value)
rec = props.get(RECURRENCE_KEY) or {}
for rule in rec.values():
if isinstance(rule, dict) and rule.get("timezone"):
return rule["timezone"]
auto_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
(collection_id,),
).fetchall()
for ap in auto_props:
value = props.get(str(ap["id"]))
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
for person in people:
if isinstance(person, dict) and person.get("id"):
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (person["id"],)
).fetchone()
if row and row["timezone"]:
return row["timezone"]
return ""
def scan_and_fire(now: dt.datetime | None = None) -> int:
"""Fire every reminder that is due. Returns the number of notifications
created. Safe to call repeatedly (dedup via reminder_log)."""
now = now or dt.datetime.now(dt.UTC)
fired = 0
with get_conn() as conn:
pages = conn.execute(
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
).fetchall()
for page in pages:
try:
props = json.loads(page["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
reminders = props.get(REMINDER_KEY)
if not isinstance(reminders, dict):
continue
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
for prop_key, reminder in reminders.items():
lead = parse_lead(reminder)
if lead is None:
continue
base_value = props.get(str(prop_key))
if not base_value and str(prop_key).isdigit():
# fall back to name-keyed storage
pname = conn.execute(
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
).fetchone()
if pname:
base_value = props.get(pname["name"])
if parse_date(base_value) is None:
continue
rule = rec_all.get(str(prop_key))
if rule is None and not str(prop_key).isdigit():
pid = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(page["collection_id"], str(prop_key)),
).fetchone()
if pid:
rule = rec_all.get(str(pid["id"]))
tz_name = _row_tz(conn, page["collection_id"], props)
# Look for the occurrence whose reminder moment is in
# [now - grace, now].
events = next_occurrences(
base_value, rule, tz_name or None,
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
horizon_days=400, max_count=2000,
)
due = None
for _d, event in events:
fire_at = event - dt.timedelta(seconds=lead)
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
due = (event, fire_at)
break
if due is None:
continue
event, _fire_at = due
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
continue
target_ids = _person_targets(conn, page["collection_id"], props)
if not target_ids:
row = conn.execute(
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
).fetchone()
target_ids = [row["id"]] if row else []
for uid in dict.fromkeys(target_ids):
cur = conn.execute(
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
(page["id"], event.date().isoformat()),
)
if cur.rowcount != 1:
continue
from app.services import mailer, notifications
lead_text = lead_human(reminder)
notifications.create_notification(
uid, None, "reminder",
title=f"Reminder: {page['title'] or 'Untitled'}",
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
if lead else
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
resource_type="db_page",
resource_id=page["id"],
url=f"/collections/{page['collection_id']}",
conn=conn, commit=False,
)
mailer.notify_user(
uid,
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
cta_url=f"/collections/{page['collection_id']}",
prefs_key="reminders",
)
fired += 1
conn.commit()
return fired
async def reminder_scheduler(interval_seconds: int = 60):
"""Background loop: scan for due reminders once a minute."""
import asyncio
from app.config import settings
if not getattr(settings, "reminders_enabled", True):
logger.info("Reminder scheduler disabled via settings")
return
await asyncio.sleep(5)
while True:
try:
n = scan_and_fire()
if n:
logger.info("Reminders: fired %d notification(s)", n)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Reminder scan failed: %s", exc)
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))
+5 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import statistics
from typing import Any
from typing import Any, Optional
from app.db import get_conn
@@ -105,12 +105,12 @@ class RollupEngine:
return ROLLUP_FUNCTIONS[rollup_function](values)
def _safe_avg(values: list) -> float | None:
def _safe_avg(values: list) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
def _safe_stat(values: list, fn) -> float | None:
def _safe_stat(values: list, fn) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
@@ -123,12 +123,12 @@ def _numeric(gen):
pass
def _safe_range(values: list) -> float | None:
def _safe_range(values: list) -> Optional[float]:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
def _percent_checked(values: list) -> float | None:
def _percent_checked(values: list) -> Optional[float]:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
-186
View File
@@ -1,186 +0,0 @@
"""FlowDeck — unified search (v5.0.0).
Powers the Ctrl+K command palette. Searches editor pages and databases
(collections) with SQLite FTS5 when available, falling back to ``LIKE`` scans
otherwise. Results are scoped to the workspaces the current user can access.
"""
from __future__ import annotations
import logging
import re
from app.db import get_conn
from app.migrations import fts5_available
logger = logging.getLogger(__name__)
# ── FTS5 helpers ────────────────────────────────────────────────────────────
def _fts_terms(query: str) -> list[str]:
"""Split a user query into safe FTS5 prefix terms."""
tokens = re.findall(r"[\wÀ-ÿ]+", query, flags=re.UNICODE)
return [t.replace('"', '""') for t in tokens if t]
def _fts_match(query: str) -> str | None:
"""Build a MATCH expression, or None when the query is not FTS-safe."""
terms = _fts_terms(query)
if not terms:
return None
return " AND ".join(f'"{t}"*' for t in terms)
def _has_fts_table(conn) -> bool:
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
).fetchone()
return row is not None
except Exception:
return False
def _extract_plain_text(content: str, content_format: str) -> str:
"""Return a readable one-line excerpt for a page raw ``content`` value."""
if not content:
return ""
fmt = content_format or "blocks"
if fmt == "blocks":
try:
import json as _json
blocks = _json.loads(content)
parts = []
for b in blocks if isinstance(blocks, list) else []:
if isinstance(b, dict):
text = b.get("content") or b.get("text") or ""
if isinstance(text, str) and text.strip():
parts.append(text.strip())
for child in (b.get("children") or []):
if isinstance(child, dict) and (child.get("content") or child.get("text")):
parts.append(str(child.get("content") or child.get("text")).strip())
return " ".join(parts)
except Exception:
return content
if fmt == "file":
return ""
return content
def _workspace_name(conn, workspace_id) -> str:
if not workspace_id:
return ""
try:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
except Exception:
return ""
def _scope_where(user_id: int | None) -> tuple[str, list]:
"""SQL filter restricting results to the user's accessible workspaces."""
if user_id is None:
return "1=1", []
return (
"(workspace_id IS NULL OR workspace_id IN ("
" SELECT id FROM workspaces WHERE owner_id = ? "
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?))",
[user_id, user_id],
)
# ── Search entry point ──────────────────────────────────────────────────────
def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
q = (query or "").strip()
if not q:
return {"pages": [], "collections": []}
with get_conn() as conn:
pages = _search_pages(conn, q, user_id, limit)
collections = _search_collections(conn, q, user_id, limit)
return {"pages": pages, "collections": collections}
def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
# 1) FTS5 fast path.
if fts5_available() and _has_fts_table(conn):
match = _fts_match(query)
if match:
try:
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format,
p.workspace_id, p.content_format
FROM pages_fts f
JOIN pages p ON p.id = f.rowid
WHERE pages_fts MATCH ? AND p.deleted_at IS NULL AND {scope}
ORDER BY rank LIMIT ?
""",
[match, *params, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
except Exception as exc: # FTS syntax/edge case → fall through to LIKE
logger.debug("FTS search failed (%s); fallback to LIKE", exc)
# 2) LIKE fallback.
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format, p.workspace_id
FROM pages p
WHERE p.deleted_at IS NULL AND {scope}
AND (p.title LIKE ? OR p.content LIKE ?)
ORDER BY p.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
rows = conn.execute(
f"""
SELECT c.id, c.name, c.description, c.icon, c.workspace_id
FROM collections c
WHERE {scope}
AND (c.name LIKE ? OR c.description LIKE ?)
ORDER BY c.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return [
{
"id": r["id"],
"type": "collection",
"title": r["name"] or "Untitled",
"subtitle": "Database" + (f" · {_workspace_name(conn, r['workspace_id'])}" if r["workspace_id"] else ""),
"icon": (r["icon"] or "📋"),
"url": f"/db/{r['id']}",
}
for r in rows
]
def _page_rows_to_results(conn, rows) -> list:
results = []
for r in rows:
title = (r["title"] or "Untitled").strip() or "Untitled"
ws = _workspace_name(conn, r["workspace_id"])
subtitle = ws or "Page"
excerpt = _extract_plain_text(r["content"], r["content_format"])
results.append({
"id": r["id"],
"type": "page",
"title": title,
"subtitle": subtitle,
"icon": "file",
"excerpt": excerpt[:160],
"url": f"/pages/{r['id']}",
})
return results
-421
View File
@@ -1,421 +0,0 @@
"""FlowDeck — offline synchronization engine (v6.0.0 PWA).
Reconciles offline mutations (queued on the client) with server state:
- ``get_delta`` — changes on the server since a given timestamp, for offline
clients to pull before pushing their own batch.
- ``apply_batch`` — replays a batch of offline mutations with optimistic
concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added
in migration v17) is the version token.
Conflict model (from docs/V6_PWA_Progressive_Web_App.md):
- edit-edit → last-write-wins by default (applied + reported)
- edit-delete→ the page was deleted server-side → orphan copy created
- create-create → same title already exists server-side → renamed "… (copie offline)"
- delete → soft-delete (idempotent)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
class ConflictError(Exception):
"""Raised when a mutation conflicts with server state."""
def __init__(self, detail: dict):
super().__init__(detail.get("type"))
self.details = detail
class SyncEngine:
"""Apply/read offline mutations. Stateless methods, thin sqlite access."""
# ── helpers ────────────────────────────────────────────────────────────
@staticmethod
def _now_epoch() -> float:
return time.time()
@staticmethod
def _can_access(conn, user_id: int, workspace_id: int | None) -> bool:
"""Owner or member of the workspace; legacy NULL workspace → allow."""
if not workspace_id:
return True
ws = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
if ws and ws["owner_id"] == user_id:
return True
member = conn.execute(
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id),
).fetchone()
return bool(member)
@staticmethod
def _page_workspace_id(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
return row["workspace_id"] if row else None
# ── delta ─────────────────────────────────────────────────────────────
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Return server-side changes since `since` (epoch seconds)."""
if since and since > 1e12:
since /= 1000.0 # accept epoch-millis from legacy clients
changes: list[dict] = []
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
return {"error": "forbidden"}
# ── pages (created / updated / soft-deleted) ──
rows = conn.execute(
"""SELECT * FROM pages
WHERE (? IS NULL OR workspace_id = ?)
AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ?
OR (deleted_at IS NOT NULL
AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""",
(workspace_id, workspace_id, since, since),
).fetchall()
for r in rows:
data = dict(r)
deleted = data.get("deleted_at") is not None
created_epoch = _iso_epoch(data.get("created_at"))
created_after = bool(created_epoch and created_epoch > since)
if deleted:
ctype = "page_deleted"
elif created_after:
ctype = "page_created"
else:
ctype = "page_updated"
changes.append({"change_type": ctype, "data": data})
# ── collections (created / updated) ──
coll_rows = conn.execute(
"SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in coll_rows:
data = dict(r)
created_epoch = _iso_epoch(data.get("created_at"))
changes.append({
"change_type": "collection_created" if created_epoch and created_epoch > since
else "collection_updated",
"data": data,
})
# ── collection rows (informational for offline reading) ──
cp_rows = conn.execute(
"SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in cp_rows:
changes.append({"change_type": "collection_row_updated", "data": dict(r)})
return {
"changes": changes,
"server_time": self._now_epoch(),
"has_more": False,
}
# ── batch ──────────────────────────────────────────────────────────────
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Apply a batch of offline mutations; returns per-mutation results.
``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}]
"""
results: list[dict] = []
conflicts: list[dict] = []
for mut in mutations:
mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}"
mtype = mut.get("type", "")
payload = mut.get("payload") or {}
client_ts = float(mut.get("client_timestamp") or 0)
result = {
"mutation_id": mut_id,
"type": mtype,
"status": "synced",
"server_version": None,
}
try:
handler = getattr(self, f"_mut_{mtype}", None)
if handler is None:
raise ValueError(f"unknown mutation type: {mtype}")
outcome = handler(user_id, payload)
result.update(outcome)
status = "conflict" if outcome.get("conflict") else "synced"
result["status"] = status
if outcome.get("conflict"):
conflicts.append({
"mutation_id": mut_id,
"type": mtype,
**outcome["conflict"],
})
except ConflictError as exc:
result["status"] = "conflict"
result["conflict"] = exc.details
conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details})
except Exception as exc: # noqa: BLE001 — report, don't kill the batch
logger.warning("sync mutation %s failed: %s", mut_id, exc)
result["status"] = "failed"
result["error"] = str(exc)
finally:
self._record(user_id, device_id, mtype, mut, client_ts, result)
results.append(result)
return {"results": results, "conflicts": conflicts}
# ── page mutations ─────────────────────────────────────────────────────
def _mut_page_create(self, user_id: int, payload: dict) -> dict:
title = (payload.get("title") or "New page").strip() or "New page"
workspace_id = payload.get("workspace_id")
parent_id = payload.get("parent_id")
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
raise ConflictError({"type": "forbidden", "workspace_id": workspace_id})
# create-create conflict: same title already present at same parent
new_title = title
dup = conn.execute(
"""SELECT id FROM pages
WHERE title=? AND (? IS NULL OR workspace_id = ?)
AND (? IS NULL OR parent_id IS ?)
AND deleted_at IS NULL
LIMIT 1""",
(title, workspace_id, workspace_id, parent_id, parent_id),
).fetchone()
if dup:
new_title = f"{title} (copie offline)"
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format,
parent_id, parent_section, sort_order)
VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""",
(workspace_id, new_title,
payload.get("content", ""),
payload.get("content_format", "blocks"),
parent_id,
payload.get("sort_order", 0)),
)
conn.commit()
page_id = cur.lastrowid
synced = payload.get("client_page_id")
return {"page_id": page_id, "server_version": 1, "client_page_id": synced,
"conflict": {"type": "create_create", "renamed": new_title != title}
if new_title != title else None}
def _mut_page_update(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
base_version = payload.get("base_version")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise ConflictError({"type": "page_not_found", "page_id": page_id})
ws_id = row["workspace_id"] or payload.get("workspace_id")
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
# edit-delete: page soft-deleted server-side → orphan copy
if row["deleted_at"] is not None:
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format, parent_section)
VALUES ('', ?, ?, ?, ?, 'Private')""",
(ws_id,
payload.get("title") or row["title"],
(payload.get("content")
if payload.get("content") is not None else row["content"]),
payload.get("content_format") or row["content_format"]),
)
conn.commit()
orphan_id = cur.lastrowid
raise ConflictError({
"type": "edit_delete",
"page_id": page_id,
"new_page_id": orphan_id,
"detail": "La page a été supprimée côté serveur — copie récréée en page orpheline",
})
# edit-edit: version mismatch → last-write-wins + conflict report
server_version = row["sync_version"]
conflict = None
if base_version is not None and server_version != base_version:
conflict = {
"type": "edit_edit",
"page_id": page_id,
"client_version": base_version,
"server_version": server_version,
}
sets, params = [], []
if payload.get("title") is not None:
sets.append("title=?")
params.append(payload["title"])
if payload.get("content") is not None:
sets.append("content=?")
params.append(payload["content"])
if payload.get("content_format") is not None:
sets.append("content_format=?")
params.append(payload["content_format"])
sets_str = ", ".join(sets) if sets else "updated_at=updated_at"
params.append(page_id)
conn.execute(
f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version,
"conflict": conflict}
def _mut_page_delete(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, "
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(page_id,),
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
def _mut_page_move(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None}
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
sets, params = [], []
if payload.get("parent_id") is not None:
sets.append("parent_id=?")
params.append(payload["parent_id"])
if payload.get("sort_order") is not None:
sets.append("sort_order=?")
params.append(payload["sort_order"])
if sets:
params.append(page_id)
conn.execute(
f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
# ── collection mutations ───────────────────────────────────────────────
def _mut_collection_create(self, user_id: int, payload: dict) -> dict:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO collections (name, description, icon, schema_json)
VALUES (?, ?, ?, ?)""",
(payload.get("name", ""), payload.get("description", ""),
payload.get("icon", "📋"), json.dumps(payload.get("schema", []))),
)
conn.commit()
return {"collection_id": cur.lastrowid, "server_version": 1}
def _mut_collection_update(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
raise ConflictError({"type": "collection_not_found", "collection_id": cid})
sets, params = [], []
if payload.get("name") is not None:
sets.append("name=?")
params.append(payload["name"])
if payload.get("description") is not None:
sets.append("description=?")
params.append(payload["description"])
if payload.get("icon") is not None:
sets.append("icon=?")
params.append(payload["icon"])
if payload.get("schema") is not None:
sets.append("schema_json=?")
params.append(json.dumps(payload["schema"]))
if sets:
params.append(cid)
conn.execute(
f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM collections WHERE id=?", (cid,)
).fetchone()["sync_version"]
return {"collection_id": cid, "server_version": new_version}
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return {"collection_id": cid, "server_version": None}
# ── audit ──────────────────────────────────────────────────────────────
def _record(self, user_id: int, device_id: str, mtype: str, mut: dict,
client_ts: float, result: dict) -> None:
"""Persist every mutation in the server-side audit queue."""
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO offline_sync_queue
(user_id, device_id, type, payload, client_timestamp,
server_version, status, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, device_id, mtype, json.dumps(mut),
client_ts, result.get("server_version"),
result.get("status", "synced"),
result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False)
if result.get("conflict") else None)),
)
conn.commit()
except Exception: # noqa: BLE001 — audit must never break the batch
logger.warning("failed to record sync audit row", exc_info=True)
def _iso_epoch(value) -> float | None:
"""Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'."""
try:
if value is None:
return None
# append a timezone so strptime behaves on naive timestamps
text = str(value).replace("T", " ").split(".")[0]
parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
return parsed.replace(tzinfo=_dt.UTC).timestamp()
except ValueError:
return None
-204
View File
@@ -1,204 +0,0 @@
"""FlowDeck — v5.14.0 Synced blocks: a block created once, displayed
and edited across multiple pages.
The ``synced_blocks`` table stores the source-of-truth content. Each
page that uses a synced block stores a reference in ``page_synced_blocks``.
Editing the source propagates to all referencing pages (via realtime
rooms or on next load).
"""
from __future__ import annotations
import json
import logging
import uuid
from app.db import get_conn
logger = logging.getLogger(__name__)
def block_id() -> str:
return "sb" + uuid.uuid4().hex[:10]
# ── SyncedBlock CRUD ──────────────────────────────────────────────
def create_synced_block(workspace: str, title: str, content: list[dict],
created_by: int | None = None) -> int:
"""Create a new synced block and return its id."""
content_json = json.dumps(content, ensure_ascii=False) if content else "[]"
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO synced_blocks (title, content, created_by, workspace)
VALUES (?, ?, ?, ?)""",
(title, content_json, created_by, workspace),
)
conn.commit()
return cur.lastrowid
def get_synced_block(synced_id: int) -> dict | None:
"""Fetch a synced block by id."""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM synced_blocks WHERE id=?", (synced_id,)
).fetchone()
if not row:
return None
return dict(row)
def update_synced_block(synced_id: int, title: str,
content: list[dict]) -> None:
"""Update a synced block's title and content."""
content_json = json.dumps(content, ensure_ascii=False)
with get_conn() as conn:
conn.execute(
"""UPDATE synced_blocks SET title=?, content=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(title, content_json, synced_id),
)
conn.commit()
def delete_synced_block(synced_id: int) -> None:
"""Delete a synced block (cascades via FK)."""
with get_conn() as conn:
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
conn.commit()
def list_synced_blocks(workspace: str) -> list[dict]:
"""List all synced blocks in a workspace."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM synced_blocks WHERE workspace=? ORDER BY updated_at DESC",
(workspace,),
).fetchall()
return [dict(r) for r in rows]
# ── Page references ───────────────────────────────────────────────
def add_page_synced(page_id: int, synced_block_id: int,
block_index: int = 0) -> None:
"""Record that a page references a synced block at a given index."""
with get_conn() as conn:
conn.execute(
"""INSERT OR IGNORE INTO page_synced_blocks
(page_id, synced_block_id, block_index)
VALUES (?, ?, ?)""",
(page_id, synced_block_id, block_index),
)
conn.commit()
def remove_page_synced(page_id: int, synced_block_id: int) -> None:
"""Remove a page's reference to a synced block."""
with get_conn() as conn:
conn.execute(
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
(page_id, synced_block_id),
)
conn.commit()
def get_page_synced(page_id: int) -> list[dict]:
"""Get all synced block references for a page."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT psb.*, sb.title, sb.content AS synced_content
FROM page_synced_blocks psb
JOIN synced_blocks sb ON sb.id=psb.synced_block_id
WHERE psb.page_id=? ORDER BY psb.block_index""",
(page_id,),
).fetchall()
return [dict(r) for r in rows]
# ── Block resolution ──────────────────────────────────────────────
def resolve_synced_block(blocks: list[dict]) -> list[dict]:
"""Replace synced block references with actual content for rendering.
A synced block in the block list looks like:
{"type": "synced", "synced_id": 42, "content": "..."}
This resolves it to the current content from the synced_blocks table.
"""
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id"):
sb = get_synced_block(b["synced_id"])
if sb:
try:
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
b["_synced_content"] = resolved
b["_synced_title"] = sb.get("title", "")
b["_synced_id"] = sb["id"]
b["_synced_updated"] = sb.get("updated_at", "")
except (json.JSONDecodeError, TypeError):
pass
if isinstance(b.get("children"), list):
resolve_synced_block(b["children"])
return blocks
def sync_synced_blocks_in_page(page_id: int) -> None:
"""Re-resolve all synced blocks in a page's content.
Called after a synced block is updated — refreshes the referencing
pages' block content so they reflect the latest source.
"""
refs = get_page_synced(page_id)
if not refs:
return
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format FROM pages WHERE id=?",
(page_id,),
).fetchone()
if not row or row["content_format"] != "blocks":
return
try:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
return
changed = False
for ref in refs:
sb = get_synced_block(ref["synced_block_id"])
if sb:
try:
resolved = json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
continue
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]:
b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved)
b["_synced_content"] = resolved
b["_synced_title"] = sb.get("title", "")
changed = True
break
if changed:
with get_conn() as conn:
conn.execute(
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(blocks, ensure_ascii=False), page_id),
)
conn.commit()
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
-918
View File
@@ -1,918 +0,0 @@
"""FlowDeck — Agent tool registry (v4.14.0).
The agent never re-invents FlowDeck: each tool is a thin wrapper over the same
operations the human-facing routers perform (create collection/page/property,
manage views, dependencies, Gitea issues, templates, workspaces & documents).
Every mutating tool returns an *undo snapshot* so AgentEngine can journal and
roll back each action.
Since v4.14.0 the registry also covers *documents* (Notion-style pages that
live inside a workspace, table ``pages``) and lets the agent read the list of
workspaces — fixing the case where "crée un document dans le workspace X"
used to end with no action.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass, field
from typing import Any
from app.db import get_conn
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
status: str # success | error | reverted
tool: str
target_type: str = ""
target_id: Any = None
message: str = ""
data: dict = field(default_factory=dict)
undo: dict = field(default_factory=dict) # snapshot to restore on rollback
class Tool:
name: str = ""
description: str = ""
parameters: dict = field(default_factory=dict)
async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult: # pragma: no cover
raise NotImplementedError
# ══════════════════════════ Read tools ══════════════════════════
class SearchWorkspace(Tool):
name = "search_workspace"
description = ("Recherche full-text (par titre) dans tout FlowDeck : collections, pages de "
"collection, documents (pages éditeur) et espaces de travail.")
parameters = {
"type": "object",
"properties": {"query": {"type": "string", "description": "terme recherché"}},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
q = f"%{args.get('query', '').strip()}%"
with get_conn() as conn:
colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall()
pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall()
docs = conn.execute(
"SELECT id, title, workspace_id, content_format FROM pages "
"WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20",
(q,),
).fetchall()
workspaces = conn.execute(
"SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (q,),
).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="search",
data={
"collections": [dict(c) for c in colls],
"pages": [dict(p) for p in pages],
"documents": [dict(d) for d in docs],
"workspaces": [dict(w) for w in workspaces],
},
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"),
)
class ReadCollection(Tool):
name = "read_collection"
description = "Lit le schéma (propriétés + vues) d'une collection."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}},
"required": ["collection_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
props = conn.execute("SELECT id, name, prop_type, options_json FROM collection_properties WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
views = conn.execute("SELECT id, name, view_type, config_json FROM collection_views WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
pages = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={
"collection": dict(coll),
"properties": [dict(p) for p in props],
"views": [dict(v) for v in views],
"pages": [dict(p) for p in pages],
},
)
class ReadPage(Tool):
name = "read_page"
description = "Lit une page d'une collection (propriétés + valeurs)."
parameters = {
"type": "object",
"properties": {"page_id": {"type": "integer"}},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
deps = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (pid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page": dict(page), "dependencies": [dict(d) for d in deps]},
)
class ReadWorkspaces(Tool):
name = "read_workspaces"
description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de "
"documents et de collections qu'ils contiennent — utile pour savoir où créer "
"ou chercher un document.")
parameters = {
"type": "object",
"properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}},
}
async def execute(self, args, *, user_id=None) -> ToolResult:
query = (args.get("query") or "").strip()
base_sql = (
"SELECT w.id, w.name, {role} AS role, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, "
"(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count "
"FROM workspaces w {join} {where} ORDER BY w.name"
)
with get_conn() as conn:
if user_id is None:
rows = conn.execute(
base_sql.format(role="'owner'", join="", where=""), []
).fetchall()
else:
rows = conn.execute(
base_sql.format(
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
),
(user_id, user_id, user_id),
).fetchall()
data = [dict(r) for r in rows]
if query:
q = query.lower()
data = [w for w in data if q in str(w.get("name", "")).lower()]
return ToolResult(
status="success", tool=self.name, target_type="workspaces",
data={"workspaces": data},
message=f"{len(data)} espace(s) de travail",
)
class ReadDocument(Tool):
name = "read_document"
description = "Lit un document (page éditeur) : titre, contenu et métadonnées."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
if not doc:
return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable")
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document": dict(doc)},
)
# ══════════════════════════ Write tools (with undo) ══════════════════════════
class CreateCollection(Tool):
name = "create_collection"
description = "Crée une collection (base de données) avec sa vue par défaut."
parameters = {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"icon": {"type": "string"},
},
"required": ["name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
name = (args.get("name") or "").strip()
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
description = args.get("description", "")
icon = args.get("icon", "📋")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,'[]')",
(name, description, icon),
)
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cid, "Default View", "table", json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={"collection_id": cid, "name": name},
undo={"action": "delete", "table": "collections", "id": cid},
)
class CreateView(Tool):
name = "create_view"
description = "Crée une vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) sur une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"view_type": {"type": "string"},
"name": {"type": "string"},
},
"required": ["collection_id", "view_type"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, vtype = args.get("collection_id"), args.get("view_type", "table")
name = args.get("name", vtype.title())
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)",
(cid, name, vtype, json.dumps({}), max_pos),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="view", target_id=cur.lastrowid,
data={"view_id": cur.lastrowid, "collection_id": cid, "view_type": vtype},
undo={"action": "delete", "table": "collection_views", "id": cur.lastrowid},
)
class AddProperty(Tool):
name = "add_property"
description = "Ajoute une propriété typée à une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"name": {"type": "string"},
"prop_type": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
},
"required": ["collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
name = (args.get("name") or "").strip()
prop_type = args.get("prop_type", "text")
options = args.get("options", [])
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
try:
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?,?,?,?,?)",
(cid, name, prop_type, json.dumps(options), max_pos),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message=f"Propriété '{name}' existe déjà")
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=cur.lastrowid,
data={"property_id": cur.lastrowid, "name": name, "prop_type": prop_type},
undo={"action": "delete", "table": "collection_properties", "id": cur.lastrowid},
)
class CreatePage(Tool):
name = "create_page"
description = "Crée une page dans une collection avec les valeurs de ses propriétés."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object", "description": "map name→valeur"},
},
"required": ["collection_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
title = (args.get("title") or "").strip()
if not title:
return ToolResult(status="error", tool=self.name, message="title est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id, is_locked FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
if coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
props = self._resolve_properties(conn, cid, args.get("properties", {}))
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, title, max_pos, json.dumps(props, ensure_ascii=False)),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "title": title},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
@staticmethod
def _resolve_properties(conn, cid, values: dict) -> dict:
"""Map human property names → property ids for the JSON blob."""
props = conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (cid,)).fetchall()
id_by_name = {r["name"]: r["id"] for r in props}
out = {}
for k, v in (values or {}).items():
key = id_by_name.get(k, k)
if isinstance(v, list):
out[str(key)] = v
else:
out[str(key)] = v
return out
class CreateDocument(Tool):
name = "create_document"
description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la "
"racine. Le contenu initial est optionnel (Markdown).")
parameters = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "titre du document"},
"content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"},
"workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"},
"workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"},
"parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"},
},
"required": ["title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
title = (args.get("title") or "").strip()
if not title:
return ToolResult(status="error", tool=self.name, message="title est requis")
content = (args.get("content") or "").strip()
ws_id = args.get("workspace_id")
ws_name = (args.get("workspace_name") or "").strip()
parent_id = args.get("parent_id") or None
with get_conn() as conn:
workspace = None
if ws_id is not None:
workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone()
if not workspace:
return ToolResult(status="error", tool=self.name,
message=f"Workspace #{ws_id} introuvable")
elif ws_name:
workspace = conn.execute(
"SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1",
(ws_name,),
).fetchone()
if not workspace:
available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall()
names = ", ".join(r["name"] for r in available) or "aucun"
return ToolResult(
status="error", tool=self.name,
message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}",
)
if parent_id:
if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL",
(parent_id,)).fetchone():
return ToolResult(status="error", tool=self.name,
message=f"Document parent #{parent_id} introuvable")
w = dict(workspace) if workspace else None
ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None)
ws_key = (w["name"] if w else "") if ws_id_val is not None else ""
if content:
fmt, store = "markdown", content
else:
fmt, store = "blocks", "[]"
cur = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format,
parent_section, parent_id, sort_order)
VALUES (?,?,?,?,?,?,?,0)""",
(ws_key, ws_id_val, title, store, fmt, "Private", parent_id),
)
pid = cur.lastrowid
conn.commit()
loc = f" dans « {ws_key} »" if ws_key else ""
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document_id": pid, "title": title,
"workspace_id": ws_id_val, "workspace": ws_key},
message=f"Document « {title} » créé{loc}",
undo={"action": "delete", "table": "pages", "id": pid},
)
class UpdatePage(Tool):
name = "update_page"
description = "Modifie le titre et/ou les valeurs de propriétés d'une page."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object"},
},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
coll = conn.execute("SELECT is_locked FROM collections WHERE id=?", (page["collection_id"],)).fetchone()
if coll and coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
new_title = args.get("title", page["title"])
props = json.loads(page["property_values_json"])
if args.get("properties"):
props.update(args["properties"])
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(props, ensure_ascii=False), pid),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page_id": pid, "title": new_title},
undo={"action": "update", "table": "collection_pages", "id": pid,
"snapshot": json.loads(page["property_values_json"]), "title": page["title"]},
)
class WriteBlocks(Tool):
name = "write_blocks"
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
"et/ou renomme son titre. Chaque bloc est un objet JSON "
"{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : "
"paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, "
"numbered_list, to_do, quote, divider, toggle, callout, code. "
"Utilise `type: \"code\"` (avec `language`) pour un script ou du code "
"multi-lignes, `heading_1`/`heading_2`/… pour les titres. "
"Le champ contenant le texte s'appelle `content`, PAS `text`.")
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
"blocks": {"type": "array",
"description": "nouveau contenu en blocs (remplace le contenu). "
"Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, "
"{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"},
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
},
"required": ["page_id"],
}
_VALID_BLOCK_TYPES = frozenset({
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
"callout", "table_of_contents", "math", "columns", "divider",
"code", "table", "button", "image", "embed", "bookmark",
"video", "audio", "meeting",
})
@classmethod
def _normalize_blocks(cls, blocks: list) -> list:
"""Normalize blocks coming from the LLM so the editor can render them.
Common LLM mistakes handled:
- ``text`` instead of ``content``
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
- ``bullet`` / ``list`` instead of ``bulleted_list``
- ``numbered`` instead of ``numbered_list``
- ``check`` / ``checkbox`` instead of ``to_do``
- ``code_block`` instead of ``code``
- ``h1`` … ``h6`` shorthand
- Missing ``id`` fields (editor assigns them on load but we add them
to keep the JSON self-contained)
"""
import uuid
_TYPE_MAP = {
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
"h6": "heading_4",
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
"numbered": "numbered_list", "numbered_list": "numbered_list",
"check": "to_do", "checkbox": "to_do", "task": "to_do",
"code_block": "code",
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
}
out = []
for block in (blocks or []):
if not isinstance(block, dict):
continue
b = dict(block)
# --- normalize type ---
raw_type = (b.get("type") or "paragraph").strip().lower()
btype = _TYPE_MAP.get(raw_type, raw_type)
if btype not in cls._VALID_BLOCK_TYPES:
btype = "paragraph"
b["type"] = btype
# --- normalize content field (LLM often sends "text" instead of "content") ---
if "content" not in b and "text" in b:
b["content"] = b.pop("text")
elif "content" not in b:
# Try other common fields
for alt in ("value", "body", "source"):
if alt in b:
b["content"] = b.pop(alt)
break
else:
b.setdefault("content", "")
# --- generate id if missing ---
if not b.get("id"):
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
out.append(b)
return out
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
has_blocks = "blocks" in args
new_title = (args.get("title") or "").strip()
if not has_blocks and not new_title:
return ToolResult(status="error", tool=self.name,
message="Fournir `blocks` et/ou `title`")
with get_conn() as conn:
page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name,
message=f"Document (page éditeur) #{pid} introuvable")
snapshot = {"content": page["content"], "content_format": page["content_format"],
"title": page["title"]}
final_title = new_title or page["title"]
if has_blocks:
normalized = self._normalize_blocks(args["blocks"])
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(normalized, ensure_ascii=False), final_title, pid),
)
else:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(final_title, pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))},
undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot},
)
class AddRelation(Tool):
name = "add_relation"
description = "Lie deux collections via une propriété relation (avec réciproque)."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"related_collection_id": {"type": "integer"},
"name": {"type": "string"},
"reverse_name": {"type": "string"},
},
"required": ["collection_id", "related_collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
rel = args.get("related_collection_id")
name = (args.get("name") or "").strip()
reverse = args.get("reverse_name", "")
if not name or not rel:
return ToolResult(status="error", tool=self.name, message="name et related_collection_id requis")
with get_conn() as conn:
for c in (cid, rel):
if not conn.execute("SELECT id FROM collections WHERE id=?", (c,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Collection #{c} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?,?,?,?,?,?)",
(cid, name, "relation", rel, reverse, max_pos),
)
prop_id = cur.lastrowid
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=prop_id,
data={"property_id": prop_id, "relation": name},
undo={"action": "delete", "table": "collection_properties", "id": prop_id},
)
class CreateSubItem(Tool):
name = "create_sub_item"
description = "Crée un sous-élément (sub-item) sous une page."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "page_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "page_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, pid = args.get("collection_id"), args.get("page_id")
title = (args.get("title") or "").strip()
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (pid, cid)).fetchone()
if not parent:
return ToolResult(status="error", tool=self.name, message="Page parent introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (pid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?,?,?,?,?)",
(cid, title, pid, max_pos, json.dumps({})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "parent_id": pid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
class AddDependency(Tool):
name = "add_dependency"
description = "Ajoute une dépendance (bloque) entre deux pages."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"dependency_id": {"type": "integer"},
"dependency_type": {"type": "string"},
},
"required": ["page_id", "dependency_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid, dep = args.get("page_id"), args.get("dependency_id")
dtype = args.get("dependency_type", "blocks")
with get_conn() as conn:
for p in (pid, dep):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (p,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Page #{p} introuvable")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?,?,?)",
(pid, dep, dtype),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message="Dépendance déjà existante")
return ToolResult(
status="success", tool=self.name, target_type="dependency", target_id=cur.lastrowid,
data={"dependency_id": cur.lastrowid, "page_id": pid},
undo={"action": "delete", "table": "page_dependencies", "id": cur.lastrowid},
)
class ApplyTemplate(Tool):
name = "apply_template"
description = "Applique un template de page dans une collection (crée une page)."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "template_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, tid = args.get("collection_id"), args.get("template_id")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
if not tmpl:
return ToolResult(status="error", tool=self.name, message="Template introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
# ══════════════════════════ Destructive tools ══════════════════════════
class DeleteDocument(Tool):
name = "delete_document"
description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
from datetime import datetime
pid = args.get("page_id")
with get_conn() as conn:
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
(pid,)).fetchone()
if not doc:
return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
message=f"Document #{pid} déplacé vers la corbeille",
undo={"action": "softdelete", "table": "pages", "id": pid},
)
class DeletePage(Tool):
name = "delete_page"
description = "Supprime une page d'une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
conn.execute("DELETE FROM collection_pages WHERE id=?", (pid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
message="Page supprimée",
undo={"action": "insert", "table": "collection_pages", "snapshot": dict(page)},
)
class DeleteCollection(Tool):
name = "delete_collection"
description = "Supprime une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
message="Collection supprimée",
undo={"action": "insert", "table": "collections", "snapshot": dict(coll)},
)
# ══════════════════════════ Gitea tools ══════════════════════════
class ReadGiteaIssues(Tool):
name = "read_gitea_issues"
description = "Lit les issues Gitea d'un repo (état, labels, milestones)."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "state": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state=args.get("state", "open"))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issues", target_id=f"{owner}/{repo}",
data={"issues": issues, "count": len(issues)},
message=f"{len(issues)} issues",
)
class SyncGitea(Tool):
name = "sync_gitea"
description = "Synchronise les issues d'un repo Gitea vers une collection."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state="all")
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=coll_id,
data={"collection_id": coll_id, "issues": len(issues)},
message=f"{len(issues)} issues synchronisées",
undo={"action": "delete", "table": "collections", "id": coll_id} if coll_id else {},
)
class CreateGiteaIssue(Tool):
name = "create_gitea_issue"
description = "Crée une issue Gitea dans un repo."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
"required": ["owner", "repo", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
try:
issue = await client.create_issue(args["owner"], args["repo"], args["title"], args.get("body", ""))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issue", target_id=issue.get("number"),
data={"issue": issue},
message=f"Issue #{issue.get('number')} créée",
)
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
UpdatePage, WriteBlocks,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
]
class ToolRegistry:
"""Holds tool instances and exposes their LLM schemas + execution."""
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
-87
View File
@@ -1,87 +0,0 @@
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
window (default 30 days, matching the UI copy in ``trash.html``), then are
permanently deleted together with their child pages and orphaned versions.
The purge is run from a background scheduler in ``app.main`` so it is fully
automatic (no per-request cost).
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from app.db import get_conn
logger = logging.getLogger(__name__)
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
def _parse_dt(value: str) -> datetime | None:
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
if not value:
return None
try:
s = value[:19].replace("T", " ")
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
dt = datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
if not ISO_RE.match(value):
return None
return dt
def purge_expired(days: int = 30) -> dict:
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
Children are re-parented to their grandparent before deletion in order
to avoid silently dropping whole sub-trees; deleted pages are removed
together with their ``page_versions`` (FK cascade).
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
for row in rows:
dt = _parse_dt(row["deleted_at"])
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
"WHERE parent_id=?",
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
purged.append(page_id)
conn.commit()
if purged:
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
return {"purged": purged, "count": len(purged)}
async def trash_purge_scheduler(interval_hours: int = 24):
"""Background loop purging the trash once a day (idempotent, cheap when
there is nothing expired)."""
import asyncio
# Run once shortly after startup, then on the configured interval.
await asyncio.sleep(5)
while True:
try:
purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600)
+1
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
import json
import logging
import httpx
-90
View File
@@ -1,90 +0,0 @@
"""FlowDeck — Wiki-links & page mentions (v5.11.0).
Inline references live inside plain-text block content as tokens::
[[fdpage:123]] link to page id 123, label resolved at render time
[[fddate:2026-10-01]] date chip (static label)
The page token stores only the id, so renaming a page propagates everywhere
(the label is resolved through the DB / the titles endpoint at render time).
Backlinks keep working because the server renderer emits ``/pages/<id>``
anchors, and the backlink scanner also matches the raw token.
Pure-stdlib helpers shared by the public renderer and the block export.
"""
from __future__ import annotations
import html as _html
import re
WIKI_PAGE_RE = re.compile(r"\[\[fdpage:(\d+)\]\]")
WIKI_DATE_RE = re.compile(r"\[\[fddate:(\d{4}-\d{2}-\d{2})(?:T[0-9:]{5,8})?\]\]")
_DATE_LABELS = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def extract_page_ids(text: str) -> list[int]:
"""All page ids referenced by wiki tokens in a content string."""
return sorted({int(m) for m in WIKI_PAGE_RE.findall(text or "")})
def _date_label(iso: str) -> str:
try:
parts = iso.split("-")
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
import datetime as _dt
wd = _dt.date(y, m, d).weekday()
return f"{_DATE_LABELS[wd]} {d} {_MONTHS[m-1]} {y}"
except (ValueError, IndexError):
return iso
def token_labels(conn, content: str) -> dict[str, str]:
"""Map every token in ``content`` to its display label (page title)."""
out: dict[str, str] = {}
ids = extract_page_ids(content)
if ids:
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"SELECT id, title, page_icon FROM pages WHERE id IN ({placeholders})", ids,
).fetchall()
by_id = {str(r["id"]): (r["title"] or "Untitled", r["page_icon"] or "") for r in rows}
for token_id in ids:
title, icon = by_id.get(str(token_id), ("Deleted page", ""))
out[f"[[fdpage:{token_id}]]"] = f"{icon + ' ' if icon else ''}{title}"
for m in WIKI_DATE_RE.finditer(content or ""):
iso = m.group(1)
out[f"[[fddate:{iso}]]"] = _date_label(iso)
return out
def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
"""Escape plain-text content then turn wiki tokens into HTML chips/links.
``titles`` maps token → label (from :func:`token_labels`). Used by the
public page renderer and any server-side HTML output of block content.
"""
s = _html.escape(content or "")
def _page(m: re.Match) -> str:
pid = m.group(1)
label = titles.get(m.group(0)) or "Deleted page"
return (f'<a class="fd-wiki-link" href="/pages/{pid}" '
f'data-pid="{pid}" title="{_html.escape(label)}">{_html.escape(label)}</a>')
def _date(m: re.Match) -> str:
iso = m.group(1)
label = titles.get(f"[[fddate:{iso}]]") or iso
return f'<span class="fd-wiki-date" title="{_html.escape(iso)}">{_html.escape(label)}</span>'
s = WIKI_PAGE_RE.sub(_page, s)
s = WIKI_DATE_RE.sub(_date, s)
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)

Some files were not shown because too many files have changed in this diff Show More