Compare commits
171
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d986959927 | ||
|
|
714642363b | ||
|
|
df78badd0c | ||
|
|
9836c85e24 | ||
|
|
61a33a7891 | ||
|
|
e707becbf8 | ||
|
|
da3e09c215 | ||
|
|
19e8ba0e4a | ||
|
|
7a6c66a609 | ||
|
|
3bb17eb984 | ||
|
|
bdc15c7328 | ||
|
|
c435e277f2 | ||
|
|
d7e0ace2b7 | ||
|
|
292f3b5851 | ||
|
|
d1d8c6fd2a | ||
|
|
d50fed52ce | ||
|
|
d477c1e058 | ||
|
|
ba363eaee9 | ||
|
|
881c3e3e0a | ||
|
|
c36082be6a | ||
|
|
fbc8d657e7 | ||
|
|
cef01dffaf | ||
|
|
da1fccb38f | ||
|
|
f1dd9d6181 | ||
|
|
6fe5d2f723 | ||
|
|
5c350ff8f6 | ||
|
|
e9b6244ef0 | ||
|
|
f09de98406 | ||
|
|
3b27c57230 | ||
|
|
91b4e8d0e5 | ||
|
|
511ad942cc | ||
|
|
d40fdcafe3 | ||
|
|
15cc2d6f21 | ||
|
|
0112a5b5d8 | ||
|
|
b3c90efa73 | ||
|
|
f84ff201ea | ||
|
|
bb12763a41 | ||
|
|
3913f9f129 | ||
|
|
f8df0e13b5 | ||
|
|
32c81f156d | ||
|
|
8b0a0aea3a | ||
|
|
52d7a0d006 | ||
|
|
bd582866d1 | ||
|
|
b15289b4bc | ||
|
|
6356cd5703 | ||
|
|
3956f1ffa5 | ||
|
|
d96fb4171e | ||
|
|
63a41ade48 | ||
|
|
43aca1a1f7 | ||
|
|
d6bb424021 | ||
|
|
4dc06eb203 | ||
|
|
e4b196a528 | ||
|
|
0c271d5972 | ||
|
|
65559e5069 | ||
|
|
e4d17eb96c | ||
|
|
2391de418d | ||
|
|
917a04d543 | ||
|
|
113f880863 | ||
|
|
571d78115b | ||
|
|
27c9eb98db | ||
|
|
3025a7a44e | ||
|
|
75b7f29826 | ||
|
|
0b63ed17bc | ||
|
|
b594458b6e | ||
|
|
c322f30801 | ||
|
|
bd109c273a | ||
|
|
f57f66a93a | ||
|
|
9ba3480d4e | ||
|
|
34368a4946 | ||
|
|
1c11b9d351 | ||
|
|
3cb9edc1f3 | ||
|
|
e752e46583 | ||
|
|
9eedfa67ca | ||
|
|
56fa5dcd61 | ||
|
|
667eb6534d | ||
|
|
b60cc8a7c6 | ||
|
|
23df10732b | ||
|
|
c809a864e6 | ||
|
|
6e30589133 | ||
|
|
fa97f07ec8 | ||
|
|
5390a6ceab | ||
|
|
aa2db0103d | ||
|
|
2bdf5e4166 | ||
|
|
f7f5bae336 | ||
|
|
814dbe8c2e | ||
|
|
d12681720d | ||
|
|
e64a60c42b | ||
|
|
151ae4a3aa | ||
|
|
4939eb5a12 | ||
|
|
dcd7932a58 | ||
|
|
7c922088c8 | ||
|
|
6ebfc245f1 | ||
|
|
59fc7b7975 | ||
|
|
049861828d | ||
|
|
c586513b03 | ||
|
|
a7289db621 | ||
|
|
9c4de01fd2 | ||
|
|
461492eede | ||
|
|
ea81e85848 | ||
|
|
50273fcb1a | ||
|
|
24a760c5eb | ||
|
|
f7d87e6555 | ||
|
|
1b3aed19ff | ||
|
|
c7c6e52deb | ||
|
|
4bceb7ce91 | ||
|
|
cd6dac9ea6 | ||
|
|
afe670bdd3 | ||
|
|
e32abfbfa6 | ||
|
|
514b1da9a7 | ||
|
|
d19668e60f | ||
|
|
99fee56089 | ||
|
|
d13f13785b | ||
|
|
348793ba13 | ||
|
|
cbba7c506a | ||
|
|
38a188e6e2 | ||
|
|
50aed9e357 | ||
|
|
31db48a40d | ||
|
|
45eefa0c11 | ||
|
|
c4ff0a41e0 | ||
|
|
b76aaad5ee | ||
|
|
c4d654676c | ||
|
|
fbd191c85b | ||
|
|
288f99d81e | ||
|
|
13e0bfb28a | ||
|
|
16abeb9def | ||
|
|
44bf469c53 | ||
|
|
3c5eac3429 | ||
|
|
db4329eee2 | ||
|
|
122ccfb3c3 | ||
|
|
59a62ff2ad | ||
|
|
26d41cb864 | ||
|
|
10cbd7f52e | ||
|
|
ec96619341 | ||
|
|
d7c1f4c03e | ||
|
|
3fcfa8fdbb | ||
|
|
ce2bae1f1b | ||
|
|
85b4d624b8 | ||
|
|
ef8d4e6bca | ||
|
|
27352c84e5 | ||
|
|
bf329a4ba0 | ||
|
|
b505cfc156 | ||
|
|
3079d53cab | ||
|
|
d3bada54f0 | ||
|
|
eac6e5aed3 | ||
|
|
55853db625 | ||
|
|
fd1a4319b4 | ||
|
|
b818bc908f | ||
|
|
8a94c3b676 | ||
|
|
53d31572e6 | ||
|
|
3fcc12ad8c | ||
|
|
b88a972531 | ||
|
|
3d10400f8f | ||
|
|
9e45ea8870 | ||
|
|
d94dd3da99 | ||
|
|
aa434e9fe9 | ||
|
|
45911438f0 | ||
|
|
5ac8dd10b2 | ||
|
|
eca4067c82 | ||
|
|
fff502a700 | ||
|
|
fbd4b836f7 | ||
|
|
b835dd6b5e | ||
|
|
5372f4f22f | ||
|
|
39b485622d | ||
|
|
aba771400d | ||
|
|
2ca27d3398 | ||
|
|
9422753e8b | ||
|
|
3a94b06af4 | ||
|
|
b0d222b81a | ||
|
|
0ea447ee6f | ||
|
|
63773cb904 | ||
|
|
ec3ad6b1de |
@@ -9,6 +9,13 @@ 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
|
||||
@@ -24,3 +31,25 @@ 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
|
||||
|
||||
+39
-10
@@ -1,28 +1,57 @@
|
||||
name: FlowDeck CI
|
||||
|
||||
on:
|
||||
# Run on every pushed branch so feature branches are validated before the PR.
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-cov
|
||||
- name: Run tests with coverage
|
||||
- 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.
|
||||
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
|
||||
env:
|
||||
GITEA_URL: https://git.dracodev.net
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
APP_SECRET_KEY: ci-test-key
|
||||
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
|
||||
# `-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
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
python -m pytest tests/ --cov=app --cov-report=term 2>&1 | tail -20
|
||||
if: always()
|
||||
run: coverage report -m || true
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -4,6 +4,7 @@ __pycache__/
|
||||
/data/
|
||||
.venv/
|
||||
venv/
|
||||
.venv*/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
@@ -15,3 +16,4 @@ dist/
|
||||
.ua/tmp/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
uv.lock
|
||||
|
||||
+215
-64
@@ -1,7 +1,8 @@
|
||||
# Architecture FlowDeck — Document Complet v3.0
|
||||
# Architecture FlowDeck — Document Complet v4.0
|
||||
|
||||
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
|
||||
> **Version:** 4.0 · **Date:** 2026-07-20 · **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)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,34 +48,31 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Templates Jinja2 (SSR) │ │
|
||||
│ │ ├─ 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 │ │
|
||||
│ │ ├─ 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.) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ JavaScript (CDN, pas de build step) │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
|
||||
│ │ JavaScript (servi localement, pas de CDN externe) │ │
|
||||
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
|
||||
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
|
||||
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
|
||||
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │
|
||||
│ │ └─ Prism.js — Syntax highlighting │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
@@ -95,17 +93,27 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ ROUTERS │ │
|
||||
│ │ ├─ 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 │ │
|
||||
│ │ ├─ 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 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
@@ -138,6 +146,11 @@ 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) │ │ │
|
||||
@@ -685,42 +698,146 @@ 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. Frontend
|
||||
## 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.1 Layout
|
||||
|
||||
```
|
||||
```ascii
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ TOPBAR (44px, sticky) │
|
||||
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
|
||||
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
|
||||
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
|
||||
│ └──────────┴──────────────────────────────────┴────────────┘ │
|
||||
│ TOPBAR (unified-header) │
|
||||
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
|
||||
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
|
||||
│ └──────┴───────────────────────────────────────┴───────────┘ │
|
||||
├────────────┬─────────────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENU PRINCIPAL │
|
||||
│ (240px) │ │
|
||||
│ │ ┌─────────────────────────────────────────────┐│
|
||||
│ 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▼] ││
|
||||
│ │ ┌─ 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] [...] ││
|
||||
│ │ └─────────────────────────────────────────────┘│
|
||||
│ ────────── │ │
|
||||
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
|
||||
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
|
||||
│ footer) │ └─────────────────────────────────────────────┘│
|
||||
│ 💬 [+📄] │ │
|
||||
└────────────┴─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -1553,7 +1670,41 @@ 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
|
||||
|
||||
+1172
-1
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -1,19 +1,44 @@
|
||||
FROM python:3.12-slim
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 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
|
||||
|
||||
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 install --no-cache-dir -r 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
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data
|
||||
RUN mkdir -p /data /data/backups
|
||||
|
||||
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"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
+489
-51
@@ -195,80 +195,516 @@ 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
|
||||
- [ ] **CSRF token refresh** — après expiration session (à faire)
|
||||
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
|
||||
- [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 — Content Blocks enrichis (Notion Parity Tier 2)
|
||||
### 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)
|
||||
> **Objectif** : parité d'édition avec Notion.
|
||||
|
||||
- [ ] **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)
|
||||
- [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
|
||||
|
||||
### 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
|
||||
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.3.0 — Collaboration
|
||||
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
||||
- [ ] **Email notifications** — changements, mentions
|
||||
### v4.7.0 — Export ✅ (2026-09-03)
|
||||
> **Objectif** : exporter une page (ou un site) depuis l'éditeur.
|
||||
|
||||
### 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)
|
||||
- [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)
|
||||
|
||||
### 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
|
||||
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.6.0 — Calendar & Meetings
|
||||
- [ ] Vue Calendrier drag & drop
|
||||
- [ ] Récurrence d'événements
|
||||
- [ ] Template Meeting Notes
|
||||
### 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.0 — AI Assistant
|
||||
- [ ] Intégration LLM pour écriture/résumé/traduction
|
||||
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
|
||||
- [ ] Auto-complétion
|
||||
### 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.8.0 — Command Palette & Recherche
|
||||
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
|
||||
- [ ] **Quick actions** — navigation, création, commandes
|
||||
### 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.9.0 — Automations
|
||||
- [ ] **Database automations** — if-this-then-that
|
||||
- [ ] **Buttons** — cliquables déclenchant actions
|
||||
### 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.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)
|
||||
### 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) — Plan par phases
|
||||
> **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). **Priorité n°1 de la roadmap.**
|
||||
> **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.
|
||||
- [ ] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [ ] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [ ] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [ ] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [ ] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [ ] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [ ] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [ ] **Tests** — harnais d'import + fixtures par source
|
||||
- [ ] **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
|
||||
- [ ] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [ ] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [ ] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [ ] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [ ] **Google Keep** — Takeout JSON + HTML
|
||||
- [ ] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen
|
||||
- [ ] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [ ] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [ ] **Google Sheets** — export CSV/XLSX
|
||||
- [ ] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
#### Phase 3 — Documents & bureautique — effort moyen
|
||||
- [ ] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [ ] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [ ] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [ ] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers
|
||||
- [ ] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [ ] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [ ] **Pocket** (CSV/HTML)
|
||||
- [ ] **Readwise** (highlights CSV/Markdown)
|
||||
- [ ] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [ ] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [ ] **OPML** (flux/outlines)
|
||||
- [ ] **Standard Notes / autres**
|
||||
|
||||
#### 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)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
|
||||
|
||||
- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI
|
||||
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
|
||||
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
|
||||
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
|
||||
- [ ] **WIP limits** — par colonne, alerte visuelle au dépassement
|
||||
- [ ] **Cartes configurables** — propriétés affichées par carte, couverture (image/icône/couleur), mode compact / détaillé
|
||||
- [ ] **Calendar avec drag & drop** — reschedule direct sur la grille
|
||||
- [ ] **Gallery avec couvertures** — image/icône comme vignette de carte
|
||||
|
||||
### v5.8.0 — Calendrier & Rappels
|
||||
> **Objectif** : calendrier complet + notifications proactives.
|
||||
|
||||
- [ ] **Vues Jour / Semaine / Mois** — calendrier complet (actuellement mois seulement)
|
||||
- [ ] **Récurrence d'événements** — daily/weekly/monthly/custom (RRULE)
|
||||
- [ ] **Support timezone** — par utilisateur + par événement
|
||||
- [ ] **Rappels** — in-app + email (avant échéance : N minutes/heures/jours)
|
||||
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
|
||||
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## v5.0.0 — Pro (futur)
|
||||
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
|
||||
> **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.
|
||||
|
||||
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
|
||||
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
|
||||
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
|
||||
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
|
||||
|
||||
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
|
||||
> **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.
|
||||
|
||||
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
|
||||
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
|
||||
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
|
||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.14.0 — Synced blocks ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
> plusieurs pages, édité une fois).
|
||||
|
||||
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [ ] **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)
|
||||
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [ ] **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
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
|
||||
- [ ] **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 (5 phases)** ⬅ **priorité n°1** — 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 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) → Phase 3 documents (Word, Google Docs, HTML, PDF)
|
||||
9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
@@ -278,9 +714,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
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)
|
||||
```
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ 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 avancée, redo, drag&drop, bookmark,
|
||||
Calendrier, AI duplicate) lightbox…)
|
||||
|
||||
*Dernière mise à jour: 2026-07-18 — Roadmap restructuré, focus MVP, stratégie de branches Gitea*
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; **plan d'import v5.6.0 détaillé en 5 phases** (socle unifié, notes/Markdown, données/tableaux, documents, signets/dev/divers) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""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"]
|
||||
|
||||
@@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider):
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
+123
-8
@@ -1,26 +1,43 @@
|
||||
"""FlowDeck — Session management with signed cookies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_session(user_data: dict) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
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.
|
||||
"""
|
||||
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
|
||||
@@ -28,10 +45,65 @@ 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."""
|
||||
@@ -58,10 +130,53 @@ 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)
|
||||
|
||||
+36
-2
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -24,8 +25,9 @@ class Settings(BaseSettings):
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2
|
||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||
# 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 = ""
|
||||
|
||||
# Webhook
|
||||
webhook_base_url: str = "http://localhost:8080"
|
||||
@@ -48,6 +50,38 @@ 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
|
||||
|
||||
# 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" # openai | anthropic | google | ollama |
|
||||
# deepseek | qwencloud | nvidia | openrouter | 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:":
|
||||
|
||||
@@ -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 '📄',
|
||||
icon TEXT DEFAULT 'file',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER REFERENCES collection_pages(id),
|
||||
gitea_issue_id INTEGER,
|
||||
@@ -462,6 +462,366 @@ 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():
|
||||
|
||||
+95
-11
@@ -1,22 +1,48 @@
|
||||
"""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.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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 dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
|
||||
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,
|
||||
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.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.services.gitea_client import gitea
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -39,13 +65,43 @@ async def lifespan(_app: FastAPI):
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
# ── 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())
|
||||
|
||||
logger.info("FlowDeck v5.11.2 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
task.cancel()
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.0.0",
|
||||
version="5.11.2",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -61,6 +117,8 @@ 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)
|
||||
@@ -72,6 +130,17 @@ 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.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -92,6 +161,21 @@ async def pwa_manifest():
|
||||
# ═══════════ 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."""
|
||||
@@ -138,10 +222,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""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/"):
|
||||
"""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:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
return HTMLResponse(NOT_FOUND_HTML, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
"""FlowDeck — Custom middleware."""
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
|
||||
__all__ = ["CSRFMiddleware"]
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
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"}
|
||||
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", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -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:; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
@@ -115,6 +115,11 @@ 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)
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""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", []))),
|
||||
)
|
||||
+9
-12
@@ -1,14 +1,11 @@
|
||||
"""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):
|
||||
@@ -16,7 +13,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: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||
sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_path_extension(self):
|
||||
@@ -34,10 +31,10 @@ class UploadValidationResult(BaseModel):
|
||||
size: int
|
||||
extension: str
|
||||
valid: bool
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def validate_upload_request(file: UploadFile) -> Optional[str]:
|
||||
def validate_upload_request(file: UploadFile) -> str | None:
|
||||
"""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
|
||||
@@ -66,12 +63,12 @@ class IssueCreateRequest(BaseModel):
|
||||
|
||||
class IssueUpdateRequest(BaseModel):
|
||||
"""Request model for updating a Gitea issue (partial update)."""
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
# ── Card Move ────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""FlowDeck — Standardized response models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
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: Optional[str] = None
|
||||
detail: str | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -29,7 +29,7 @@ class SuccessResponse(BaseModel):
|
||||
SuccessResponse(status="ok", data={"issue_id": 42})
|
||||
"""
|
||||
status: str = "ok"
|
||||
data: Optional[dict[str, Any]] = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -4,16 +4,15 @@ 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 _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
@@ -150,7 +149,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 = [l for l in current_labels if l not in status_labels]
|
||||
filtered_names = [name for name in current_labels if name not in status_labels]
|
||||
filtered_names.append(mapping["gitea_label"])
|
||||
|
||||
# Resolve label names to IDs
|
||||
@@ -294,7 +293,7 @@ async def create_issue(
|
||||
if not _check_rate_limit(request):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
|
||||
milestone_id = int(milestone) if milestone.strip().isdigit() else None
|
||||
|
||||
issue = await gitea.create_issue(
|
||||
@@ -346,7 +345,7 @@ async def update_issue_api(
|
||||
if state:
|
||||
kwargs["state"] = state
|
||||
if labels:
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
|
||||
if milestone and milestone.strip().isdigit():
|
||||
kwargs["milestone"] = int(milestone)
|
||||
if assignee:
|
||||
@@ -389,7 +388,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")
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
|
||||
|
||||
# Get checklists from local DB
|
||||
with get_conn() as conn:
|
||||
|
||||
+38
-17
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
@@ -13,6 +13,24 @@ 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>
|
||||
@@ -144,19 +162,20 @@ 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
|
||||
# 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"))
|
||||
# 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"))
|
||||
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:
|
||||
@@ -191,7 +210,7 @@ async def register(request: Request):
|
||||
user_data = dict(user)
|
||||
# Log login
|
||||
_log_login(user_data["id"], request)
|
||||
session = SessionManager.create_session(user_data)
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
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="/")
|
||||
@@ -201,10 +220,12 @@ async def register(request: Request):
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
"""Login with email + password."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import verify_password, is_locked
|
||||
import time
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
import json, time
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -246,7 +267,7 @@ async def local_login(request: Request):
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
session = SessionManager.create_session(ud)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_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="/")
|
||||
@@ -282,10 +303,10 @@ async def callback(
|
||||
if not oauth_provider:
|
||||
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
if not token_data:
|
||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||
|
||||
@@ -344,7 +365,7 @@ async def callback(
|
||||
user_data = dict(user) if user else oauth_user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
session = SessionManager.create_session(user_data, request)
|
||||
_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="/")
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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]}
|
||||
+615
-70
@@ -3,13 +3,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import gitea
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -62,7 +66,7 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "📄"
|
||||
icon = "file"
|
||||
for lbl in labels:
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
@@ -141,27 +145,41 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
||||
|
||||
|
||||
def _file_icon(name: str, content_format: str = "") -> str:
|
||||
"""Map file extension to emoji icon."""
|
||||
"""Map file extension to icon name (SVG-safe)."""
|
||||
import re
|
||||
# FlowDeck internal pages (no extension)
|
||||
if content_format and content_format != 'file':
|
||||
return '📝'
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️'
|
||||
if n.endswith('.pdf'): return '📕'
|
||||
if re.search(r'\.(md|markdown)$', n): return '📝'
|
||||
if n.endswith('.py'): return '🐍'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜'
|
||||
if re.search(r'\.(html?|xml)$', n): return '🌐'
|
||||
if n.endswith('.css'): return '🎨'
|
||||
if n.endswith('.json'): return '📋'
|
||||
if n.endswith('.sql'): return '🗃️'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n): return '💻'
|
||||
if n.endswith('.ps1'): return '⚡'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜'
|
||||
if re.search(r'\.(txt|log)$', n): return '📄'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦'
|
||||
return '📄'
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
||||
return 'image'
|
||||
if n.endswith('.pdf'):
|
||||
return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n):
|
||||
return 'edit'
|
||||
if n.endswith('.py'):
|
||||
return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.css'):
|
||||
return 'file'
|
||||
if n.endswith('.json'):
|
||||
return 'file'
|
||||
if n.endswith('.sql'):
|
||||
return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.ps1'):
|
||||
return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(txt|log)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
||||
return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
@@ -172,7 +190,9 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
ws_id = int(ws_cookie)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -180,11 +200,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
items.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
@@ -197,7 +219,9 @@ def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
@@ -205,17 +229,116 @@ def _load_children(parent_id: int) -> list:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
title = r["title"] or "Untitled"
|
||||
sub_children = _load_children(r["id"])
|
||||
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
|
||||
children.append({
|
||||
"db_id": r["id"], "name": title,
|
||||
"id": f"page/{r['id']}",
|
||||
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
|
||||
"is_folder": is_folder,
|
||||
"is_shared": is_shared,
|
||||
"child_count": len(sub_children),
|
||||
"children": sub_children,
|
||||
})
|
||||
return children
|
||||
|
||||
|
||||
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
||||
"""Return list of local workspaces for a user."""
|
||||
if not user:
|
||||
return []
|
||||
try:
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
|
||||
with get_conn() as conn:
|
||||
own_ws = (
|
||||
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
|
||||
)
|
||||
own_ws_names = (
|
||||
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
|
||||
"UNION SELECT w.name FROM workspaces w "
|
||||
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
|
||||
)
|
||||
# Scope "shared by me"-style lists to pages in the user's own workspaces
|
||||
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
|
||||
scope_cond = (
|
||||
f"(workspace_id IN ({own_ws}) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) IN "
|
||||
f"(SELECT lower(name) FROM ({own_ws_names}))) "
|
||||
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
|
||||
f"(SELECT login FROM users WHERE id=?))))"
|
||||
)
|
||||
scope_params = (user_id, user_id, user_id, user_id, user_id)
|
||||
|
||||
made_nominal = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.created_by=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
made_link = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
made_flag = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
|
||||
f"AND {scope_cond}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
published_rows = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": icon,
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
made_map = {}
|
||||
for r in (*made_nominal, *made_link, *made_flag):
|
||||
made_map.setdefault(r["id"], r)
|
||||
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_made = [_entry(r, "link") for r in made_sorted]
|
||||
received_sorted = [r for r in received_rows if r["id"] not in made_map]
|
||||
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
|
||||
shared_received = [_entry(r, "users") for r in received_sorted]
|
||||
published = [_entry(r, "globe") for r in published_rows]
|
||||
shared_all = [_entry(r, "link") for r in made_sorted]
|
||||
return shared_made, shared_received, published, shared_all
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
@@ -230,9 +353,12 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: preserve context across pages
|
||||
# Gitea workspace: preserve context across pages. Also load the local
|
||||
# mirror workspace so it appears in "My Workspaces" in the top section
|
||||
# of the sidebar, in parallel with the Gitea repository tree.
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
@@ -240,8 +366,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
workspace_pages = [] # loaded client-side
|
||||
# Get local workspace ID for mirror
|
||||
# Get local workspace ID for mirror and load its tree
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
@@ -251,6 +376,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
pass
|
||||
elif ws_cookie and user:
|
||||
@@ -267,7 +393,6 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
has_active_workspace = True
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
local_ws_id = 0
|
||||
recent = []
|
||||
if owner and repo:
|
||||
view_map = {
|
||||
@@ -280,7 +405,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
active = first
|
||||
recent.append({
|
||||
"id": f"{owner}/{repo}/{view}",
|
||||
"name": label, "icon": "📁" if first else "·",
|
||||
"name": label, "icon": "folder" if first else "",
|
||||
"url": f"/board/{owner}/{repo}?view={view}",
|
||||
"active": active, "indent": indent,
|
||||
"depth": indent, "has_children": False, "children": [],
|
||||
@@ -317,31 +442,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"children": [],
|
||||
})
|
||||
|
||||
# Load shared pages (anyone with link or published)
|
||||
with get_conn() as conn:
|
||||
shared_rows = conn.execute(
|
||||
"SELECT id, title, workspace, share_mode, published FROM pages "
|
||||
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
shared_pages = []
|
||||
published_pages = []
|
||||
for r in shared_rows:
|
||||
page_entry = {
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "🌐" if r["published"] else "🔗",
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
}
|
||||
if r["published"]:
|
||||
published_pages.append(page_entry)
|
||||
else:
|
||||
shared_pages.append(page_entry)
|
||||
# Load shared pages
|
||||
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
|
||||
|
||||
# Auth method & OAuth badge data
|
||||
auth_method = "local"
|
||||
@@ -375,12 +477,17 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"current_page": repo or "Dashboard", "last_edited": "now",
|
||||
"recent_pages": recent, "private_pages": private_items,
|
||||
"favorite_pages": favorites, "shared_pages": shared_pages,
|
||||
"shared_made_pages": shared_made_pages,
|
||||
"shared_received_pages": shared_received_pages,
|
||||
"published_pages": published_pages,
|
||||
"user": user,
|
||||
"auth_method": auth_method,
|
||||
"gitea_linked": gitea_linked,
|
||||
"github_linked": github_linked,
|
||||
"has_active_workspace": has_active_workspace}
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -510,7 +617,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
"icon": "📄",
|
||||
"url": f"/pages/{page['id']}",
|
||||
"created_by": "You",
|
||||
"source": page.get("workspace") or "🔒 Private",
|
||||
"source": page.get("workspace") or "Private",
|
||||
"last_edited": page.get("updated_at", "now"),
|
||||
"last_visited": page.get("updated_at", "now"),
|
||||
})
|
||||
@@ -518,6 +625,8 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
sidebar["favorite_pages"] = []
|
||||
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
|
||||
sidebar["shared_pages"] = []
|
||||
sidebar["shared_made_pages"] = []
|
||||
sidebar["shared_received_pages"] = []
|
||||
template = env.get_template("library.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
@@ -571,7 +680,6 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
"""Save share settings for a page."""
|
||||
import json
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
@@ -584,6 +692,33 @@ async def update_share(request: Request, page_id: int):
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
import secrets
|
||||
slug = "p-" + secrets.token_urlsafe(8)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
@@ -727,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property already exists: {e}")
|
||||
raise HTTPException(409, f"Property already exists: {e}") from e
|
||||
return {"status": "ok", "name": name, "type": prop_type}
|
||||
|
||||
|
||||
@@ -781,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
if not issue.get("pull_request"):
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
raise HTTPException(500, str(e)) from e
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
@@ -795,7 +930,7 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else "Untitled"
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
# Compute next sort_order for this parent
|
||||
@@ -813,6 +948,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -843,18 +980,27 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown"})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
"""Save blocks JSON content (Notion-style block editor)."""
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks_json = json.dumps(body.get("blocks", []))
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
@@ -862,14 +1008,406 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
||||
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
||||
prev = conn.execute(
|
||||
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
||||
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if prev is not None and prev["blocks_json"] == blocks_json:
|
||||
if prev["title"] != (title or ""):
|
||||
conn.execute(
|
||||
"UPDATE page_versions SET title=? WHERE id="
|
||||
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
||||
(title or "", page_id),
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
||||
(page_id, user_id, title or "", blocks_json),
|
||||
)
|
||||
|
||||
|
||||
def _block_texts(b: dict) -> list[str]:
|
||||
"""Flatten a block (including children) into searchable text chunks."""
|
||||
out = []
|
||||
raw = b.get("content")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append(raw)
|
||||
for child in b.get("children") or []:
|
||||
out.extend(_block_texts(child))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
async def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and f"/pages/{page_id}" in text:
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
elif fmt == "markdown":
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
elif r["content"]:
|
||||
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
async def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
page = dict(row)
|
||||
|
||||
def copy_tree(src_id: int, parent_id) -> int:
|
||||
with get_conn() as conn:
|
||||
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
||||
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
||||
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
||||
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
||||
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
||||
(parent_id, src_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
for child in conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
||||
).fetchall():
|
||||
copy_tree(child["id"], new_id)
|
||||
return new_id
|
||||
|
||||
new_id = copy_tree(page_id, page.get("parent_id"))
|
||||
title = (page.get("title") or "Untitled") + " copy"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
ws_id = int(cookie)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if row and row["workspace_id"]:
|
||||
return int(row["workspace_id"])
|
||||
return 1
|
||||
|
||||
|
||||
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
||||
{file_url, file_path, mime_type, size, file_name}."""
|
||||
import datetime
|
||||
import re as _re
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
||||
return {
|
||||
"file_url": f"/api/files/{ws_id}/{final}",
|
||||
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
||||
"mime_type": mime,
|
||||
"size": (folder / final).stat().st_size,
|
||||
"file_name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/cover")
|
||||
async def set_page_cover(request: Request, page_id: int):
|
||||
"""v5.5.0: upload an image cover for a page.
|
||||
|
||||
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
||||
an image stored in the workspace's uploads directory.
|
||||
"""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if ctype.startswith("application/json"):
|
||||
body = await request.json()
|
||||
cover_url = (body.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
raise HTTPException(400, "cover_url required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
||||
ws_id = _ws_id_for(request, page_id)
|
||||
meta = await _store_uploaded_file(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
async def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
body = await request.json()
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "icon": icon}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
|
||||
|
||||
|
||||
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
|
||||
"""Convert markdown → blocks (server-side, same mapping as the editor) and
|
||||
create a page in the caller's workspace."""
|
||||
from app.services.export import _md_to_blocks
|
||||
|
||||
blocks = _md_to_blocks(markdown or "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
file_title = title.strip() or "Import"
|
||||
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
|
||||
if not blocks:
|
||||
blocks = [{"type": "paragraph", "content": markdown or ""}]
|
||||
with get_conn() as conn:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
|
||||
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
|
||||
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
@router.post("/api/pages/import")
|
||||
async def import_page(request: Request):
|
||||
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
markdown = body.get("markdown", "")
|
||||
title = body.get("title", "")
|
||||
if not markdown and not body.get("csv"):
|
||||
raise HTTPException(400, "markdown field required")
|
||||
if not markdown.strip():
|
||||
raise HTTPException(400, "markdown is empty")
|
||||
page_id = await _create_page_from_markdown(request, markdown, title)
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
|
||||
"workspace": ""})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.post("/api/pages/import/file")
|
||||
async def import_file(request: Request):
|
||||
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
|
||||
markdown pages) into the workspace. Returns the created page ids."""
|
||||
import io as _io
|
||||
import zipfile
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
created_ids = []
|
||||
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(_io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid zip archive") from None
|
||||
md_entries = sorted(
|
||||
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not md_entries:
|
||||
raise HTTPException(400, "No .md files found in archive")
|
||||
for name in md_entries:
|
||||
raw = zf.read(name).decode("utf-8", errors="replace")
|
||||
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
|
||||
try:
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
except Exception as exc: # noqa: BLE001 - keep importing the rest
|
||||
logger.warning("import failed for %s: %s", name, exc)
|
||||
else:
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Only text/markdown files are supported") from None
|
||||
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
|
||||
created_ids.append(await _create_page_from_markdown(request, raw, title))
|
||||
|
||||
if not created_ids:
|
||||
raise HTTPException(422, "No pages could be imported")
|
||||
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
|
||||
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
data = await fetch_og_metadata(url)
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.config import settings
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
- workspace_id: move page to a different workspace
|
||||
- parent_id: change parent (0 = root level)
|
||||
@@ -882,12 +1420,12 @@ async def move_page(request: Request, page_id: int):
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
if new_ws_id:
|
||||
# Move to a different workspace: get the workspace name
|
||||
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
|
||||
@@ -907,8 +1445,10 @@ async def move_page(request: Request, page_id: int):
|
||||
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_order, page_id),
|
||||
)
|
||||
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -922,12 +1462,15 @@ async def delete_page(request: Request, page_id: int):
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files."""
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
with get_conn() as conn:
|
||||
@@ -949,7 +1492,7 @@ async def view_page(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
@@ -981,11 +1524,13 @@ async def view_page(request: Request, page_id: int):
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_data": page_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id}
|
||||
template = env.get_template("page_editor.html")
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
response = template.render(**ctx)
|
||||
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
||||
|
||||
@@ -1015,4 +1560,4 @@ async def sync_project(owner: str, repo: str):
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
raise HTTPException(500, str(e)) from e
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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"}
|
||||
+979
-20
File diff suppressed because it is too large
Load Diff
+527
-71
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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,7 +92,6 @@ 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()
|
||||
@@ -140,7 +139,6 @@ 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", "")
|
||||
@@ -162,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {"labels": [
|
||||
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
|
||||
for l in labels
|
||||
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
|
||||
for lbl in labels
|
||||
]}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
@@ -214,9 +212,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)
|
||||
@@ -255,9 +253,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)
|
||||
@@ -329,7 +327,8 @@ 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()
|
||||
|
||||
+103
-24
@@ -3,11 +3,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["library"], prefix="/api/library")
|
||||
@@ -60,9 +59,12 @@ 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 = "📝"
|
||||
|
||||
@@ -70,6 +72,7 @@ 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),
|
||||
@@ -84,6 +87,8 @@ 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": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +103,30 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
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
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
@@ -124,7 +152,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.parent_id, p.share_mode, p.parent_section, p.page_icon"
|
||||
)
|
||||
|
||||
|
||||
@@ -173,10 +201,10 @@ async def library_favorites(
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
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"
|
||||
"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"
|
||||
)
|
||||
params: list = [uid]
|
||||
if tree:
|
||||
@@ -197,13 +225,45 @@ 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)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
|
||||
# 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] = []
|
||||
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)
|
||||
@@ -213,6 +273,11 @@ 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}
|
||||
|
||||
@@ -270,7 +335,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."""
|
||||
uid = _get_user_id(request)
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
@@ -296,9 +361,12 @@ 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(
|
||||
@@ -324,6 +392,8 @@ 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 "",
|
||||
})
|
||||
|
||||
@@ -404,9 +474,12 @@ 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(
|
||||
@@ -432,6 +505,8 @@ 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 "",
|
||||
})
|
||||
|
||||
@@ -439,10 +514,14 @@ 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"
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
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_login = user.get("login", "admin") if user else "admin"
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
|
||||
@@ -22,6 +22,7 @@ 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", ""))
|
||||
@@ -45,6 +46,7 @@ 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", ""))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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"):
|
||||
if key in body:
|
||||
prefs[key] = bool(body[key])
|
||||
notif.set_user_prefs(user["id"], prefs)
|
||||
return {"status": "ok", "prefs": prefs}
|
||||
|
||||
|
||||
@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]}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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"]}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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()}
|
||||
+46
-10
@@ -1,12 +1,13 @@
|
||||
"""FlowDeck — Public API router (v2.1.0)."""
|
||||
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Header, Depends
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,28 +15,63 @@ 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 POST /api/v1/token.")
|
||||
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
|
||||
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 not row:
|
||||
if row:
|
||||
return token
|
||||
owner = _token_owner(token)
|
||||
if not owner:
|
||||
raise HTTPException(403, "Invalid API token")
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token."""
|
||||
"""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", ""))
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
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.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""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"]),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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"}
|
||||
+62
-9
@@ -6,10 +6,10 @@ import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -61,16 +61,41 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
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"]),
|
||||
)
|
||||
# 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
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_email": email,
|
||||
@@ -79,6 +104,34 @@ 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."""
|
||||
@@ -148,7 +201,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."""
|
||||
user = _require_auth(request)
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""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}
|
||||
@@ -1,12 +1,12 @@
|
||||
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
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))
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
|
||||
+299
-13
@@ -5,12 +5,14 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -230,8 +232,9 @@ async def create_db_template(request: Request):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
|
||||
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
|
||||
"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", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
@@ -239,22 +242,16 @@ 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")
|
||||
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", "{}"),
|
||||
)
|
||||
collection_id = create_from_template(conn, name, dict(tmpl))
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
|
||||
return {"collection_id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
@@ -293,9 +290,298 @@ 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")
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""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
|
||||
@@ -0,0 +1,330 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,400 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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
|
||||
@@ -6,7 +6,6 @@ without breaking the existing board routes.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
|
||||
def from_card(card_row, gitea_issue: dict | None = 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")
|
||||
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
|
||||
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
|
||||
def get_board_as_collection(owner: str, repo: str) -> dict | None:
|
||||
"""Récupère un board spécifique comme collection."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
|
||||
return GiteaBoardCompat.from_board(dict(row))
|
||||
|
||||
@staticmethod
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
|
||||
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
|
||||
"""Récupère les cartes d'un board comme collection_pages."""
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
|
||||
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
|
||||
"""Sync un board Gitea vers une vraie collection.
|
||||
|
||||
Crée ou met à jour une collection liée à Gitea et importe les pages.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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 et décisions.",
|
||||
"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": "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
|
||||
@@ -0,0 +1,278 @@
|
||||
"""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>'
|
||||
)
|
||||
@@ -0,0 +1,861 @@
|
||||
"""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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">"))
|
||||
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+", 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
|
||||
mtodo = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", stripped)
|
||||
if mtodo:
|
||||
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
|
||||
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"")
|
||||
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))
|
||||
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))
|
||||
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()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
@@ -214,7 +214,7 @@ class FormulaEngine:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
def _end(self, ctx: dict, s: Any) -> str | None:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FlowDeck — GitHub API adapter with caching."""
|
||||
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -8,26 +8,36 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.forge_adapter import ForgeAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TTL = 30 # seconds
|
||||
|
||||
|
||||
class GitHubAdapter:
|
||||
class GitHubAdapter(ForgeAdapter):
|
||||
"""Async GitHub API client (v3 REST) with simple TTL cache.
|
||||
|
||||
Authenticated via OAuth2 Bearer token.
|
||||
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
|
||||
interface so Gitea and GitHub repos can be synced identically.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
|
||||
kind = "github"
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
|
||||
transport: httpx.BaseTransport | None = None) -> 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:
|
||||
@@ -48,7 +58,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
@@ -81,7 +91,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
# Resolve default branch commit SHA if not provided
|
||||
if sha is None:
|
||||
repo_info = await client.get(
|
||||
@@ -128,7 +138,7 @@ class GitHubAdapter:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -146,11 +156,64 @@ class GitHubAdapter:
|
||||
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()
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
"""Check whether the access token is still valid."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user",
|
||||
headers=self._headers,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""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*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
|
||||
(* routed through an OpenAI-compatible gateway / any configured api_base)
|
||||
|
||||
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.
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
||||
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
||||
"offline": (None, None),
|
||||
}
|
||||
|
||||
# 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"],
|
||||
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||
"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"],
|
||||
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||
"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"
|
||||
|
||||
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."
|
||||
))
|
||||
raise
|
||||
|
||||
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
|
||||
@@ -0,0 +1,409 @@
|
||||
"""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_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 and api_base.strip():
|
||||
cfg["api_base"] = api_base.strip()
|
||||
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": name.capitalize(),
|
||||
"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 upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
|
||||
api_base: str = "", 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."""
|
||||
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 "")
|
||||
new_base = api_base if api_base else (existing.get("api_base", "") if existing else "")
|
||||
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 use `GET {base}/models` with a Bearer token;
|
||||
Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`.
|
||||
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 provider == "google":
|
||||
headers = {"x-goog-api-key": api_key}
|
||||
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)
|
||||
resp.raise_for_status()
|
||||
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)]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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 →</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)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""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}
|
||||
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))}
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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)
|
||||
@@ -2,8 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
# ── 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: Optional[list] = None) -> tuple[bool, str]:
|
||||
def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
|
||||
"""Validate a property value against its type. Returns (ok, error_message)."""
|
||||
if value is None:
|
||||
return True, ""
|
||||
@@ -149,10 +149,88 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list]
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
|
||||
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 get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
return datetime.now(UTC).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
if user:
|
||||
return {"id": user.get("id"), "login": user.get("login")}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""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 time
|
||||
return f"b{int(time.time()*1000)}"
|
||||
|
||||
|
||||
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()
|
||||
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 = 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.room(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}
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -105,12 +105,12 @@ class RollupEngine:
|
||||
return ROLLUP_FUNCTIONS[rollup_function](values)
|
||||
|
||||
|
||||
def _safe_avg(values: list) -> Optional[float]:
|
||||
def _safe_avg(values: list) -> float | None:
|
||||
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) -> Optional[float]:
|
||||
def _safe_stat(values: list, fn) -> float | None:
|
||||
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) -> Optional[float]:
|
||||
def _safe_range(values: list) -> float | None:
|
||||
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) -> Optional[float]:
|
||||
def _percent_checked(values: list) -> float | None:
|
||||
"""Percentage of true values (for checkbox properties)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""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
|
||||
@@ -0,0 +1,918 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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,7 +1,6 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
{###############################################################################
|
||||
_ctx_menu.html — LE menu contextuel UNIQUE de FlowDeck
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Inclus par library.html ET local_workspace.html (les 2 seules pages du
|
||||
contexte fichier). Ce partial est LE SEUL menu : son markup décrit l'UNION
|
||||
des options des deux pages d'origine. Les exécutions sont déléguées par
|
||||
chaque page au travers d'un store Alpine enregistré PLUS BAS (IIFE + défense
|
||||
d'exécution unique → include 2 fois = store 1 fois).
|
||||
|
||||
Chaque item se masque quand le handler correspondant n'existe pas sur la
|
||||
page (x-show="$store.fdCtx.has('key')") → menu structurellement identique,
|
||||
avec la capacité respective de la page au clic.
|
||||
|
||||
Handlers attendus (tous optionnels) :
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdCtxStore) return;
|
||||
if (window.Alpine) window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||
.fd-ctx-menu .menu-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;transition:background .1s;white-space:nowrap;}
|
||||
.fd-ctx-menu .menu-item:hover{background:var(--bg-hover);}
|
||||
.fd-ctx-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
|
||||
.fd-ctx-menu .menu-danger{color:var(--danger);}
|
||||
.fd-ctx-menu .menu-shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);padding-left:16px;}
|
||||
.fd-ctx-menu .more-sep{height:1px;background:var(--border);margin:4px 6px;}
|
||||
.fd-ctx-menu .ctx-label{color:var(--text-dim);font-size:11px;cursor:default;text-transform:uppercase;letter-spacing:.04em;}
|
||||
.fd-ctx-menu .ctx-label:hover{background:transparent;}
|
||||
.fd-ctx-menu .ctx-empty{color:var(--text-dim);font-size:11px;cursor:default;}
|
||||
.fd-ctx-menu .ctx-empty:hover{background:transparent;}
|
||||
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
|
||||
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
|
||||
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
|
||||
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;max-height:38vh;overflow-y:auto;}
|
||||
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
|
||||
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
|
||||
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
|
||||
.fd-ctx-menu .ctx-swatch.selected{border-color:var(--text-primary);}
|
||||
.fd-ctx-menu .ctx-input{flex:1;min-width:0;font-size:12px;padding:3px 6px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
.fd-ctx-menu .ctx-input:focus{border-color:var(--accent);}
|
||||
.fd-ctx-menu .ctx-btn{padding:3px 10px;font-size:12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;}
|
||||
.fd-ctx-menu .ctx-icons{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:2px 4px 4px;}
|
||||
.fd-ctx-menu .ctx-icon{width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:transparent;border:none;border-radius:4px;cursor:pointer;}
|
||||
.fd-ctx-menu .ctx-icon:hover{background:var(--bg-hover);}
|
||||
</style>
|
||||
|
||||
<div class="more-menu fd-ctx-menu" x-show="$store.fdCtx.open" x-cloak
|
||||
@click.outside="$store.fdCtx.close()"
|
||||
@keydown.escape.window="$store.fdCtx.close()"
|
||||
x-transition
|
||||
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px', maxHeight: $store.fdCtx.maxH ? $store.fdCtx.maxH + 'px' : 'calc(100vh - 16px)' }">
|
||||
|
||||
{# ── Open ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<span style="flex:1;">Open</span>
|
||||
</div>
|
||||
|
||||
{# ── Open in new tab ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('openTab')" @click="$store.fdCtx.run('openTab')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<span style="flex:1;">Open in new tab</span><span class="menu-shortcut">Ctrl+⇧+↩</span>
|
||||
</div>
|
||||
|
||||
{# ── Open in side peek ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('peek')" @click="$store.fdCtx.run('peek')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
<span style="flex:1;">Open in side peek</span><span class="menu-shortcut">Alt+Click</span>
|
||||
</div>
|
||||
|
||||
{# ── Open folder (workspace, dossiers) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('folder')" @click="$store.fdCtx.run('folder')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
<span style="flex:1;">Open folder</span>
|
||||
</div>
|
||||
|
||||
<div class="more-sep" x-show="$store.fdCtx.has('rename') || $store.fdCtx.has('setIcon') || $store.fdCtx.has('duplicate') || $store.fdCtx.has('link') || $store.fdCtx.has('move')"></div>
|
||||
|
||||
{# ── Rename ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('rename')" @click="$store.fdCtx.run('rename')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
|
||||
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
|
||||
</div>
|
||||
|
||||
{# ── Edit icon (sélecteur façon Notion) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.openIconPicker()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
<span style="flex:1;">Edit icon</span>
|
||||
</div>
|
||||
|
||||
{# ── Duplicate ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('duplicate')" @click="$store.fdCtx.run('duplicate')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Duplicate</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy link ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('link')" @click="$store.fdCtx.run('link')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
<span style="flex:1;">Copy link</span>
|
||||
</div>
|
||||
|
||||
{# ── Download file (file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span style="flex:1;">Download</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy file content (textual file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Copy content</span>
|
||||
</div>
|
||||
|
||||
{# ── Move to ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
<span style="flex:1;">Move to</span><span class="menu-shortcut">Ctrl+⇧+P</span>
|
||||
</div>
|
||||
|
||||
{# ── Favorites ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('fav')" @click="$store.fdCtx.run('fav')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
<span style="flex:1;" x-text="$store.fdCtx.node && $store.fdCtx.node.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
|
||||
{# ── Remove from Recents ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('recent')" @click="$store.fdCtx.run('recent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
<span style="flex:1;">Remove from Recents</span>
|
||||
</div>
|
||||
|
||||
{# ── Tags (workspace) ── #}
|
||||
<div class="more-sep" x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')"></div>
|
||||
<div x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')">
|
||||
<div class="menu-item ctx-label"><span style="flex:1;">Tags</span></div>
|
||||
<template x-for="t in ($store.fdCtx.node && $store.fdCtx.node.tags) || []" :key="'ct'+t.id">
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.removeTag(t.id)">
|
||||
<span style="display:flex;align-items:center;gap:8px;flex:1;overflow:hidden;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name" style="overflow:hidden;text-overflow:ellipsis;"></span></span>
|
||||
<span class="ctx-x" title="Remove tag">×</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="menu-item ctx-empty" x-show="!($store.fdCtx.node && ($store.fdCtx.node.tags||[]).length)">No tags</div>
|
||||
<div class="menu-item" x-show="$store.fdCtx.avail().length" @click.stop="$store.fdCtx.tagExistingOpen = !$store.fdCtx.tagExistingOpen">
|
||||
<span style="flex:1;">Add existing tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagExistingOpen ? '▾' : '▸'"></span>
|
||||
</div>
|
||||
<div x-show="$store.fdCtx.tagExistingOpen" class="ctx-sub">
|
||||
<template x-for="t in $store.fdCtx.avail()" :key="'ca'+t.id">
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.addExistingTag(t)">
|
||||
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name"></span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="menu-item" @click.stop="$store.fdCtx.tagAdding = !$store.fdCtx.tagAdding">
|
||||
<span style="flex:1;">+ New tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagAdding ? '▾' : '▸'"></span>
|
||||
</div>
|
||||
<div x-show="$store.fdCtx.tagAdding" class="ctx-sub">
|
||||
<div class="ctx-colors">
|
||||
<template x-for="c in $store.fdCtx.tagColors" :key="'cc'+c">
|
||||
<span class="ctx-swatch" :class="{ selected: $store.fdCtx.newTagColor === c }" :style="{ background: c }" @click.stop="$store.fdCtx.newTagColor = c"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Delete (danger) ── #}
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="$store.fdCtx.run('delete')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
<span style="flex:1;">Move to Trash</span><span class="menu-shortcut">Del</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!-- ═══════════ Database Table View (Notion-style) — Vanilla JS ═══════════ -->
|
||||
<div id="db-table-container" class="database-table-container">
|
||||
<!-- View Selector Bar -->
|
||||
<div id="db-view-bar" class="db-view-bar">
|
||||
<div class="db-view-tabs">
|
||||
<button id="db-view-tab-table" class="db-view-tab active">
|
||||
{{ fd_icon("grid",14) }} Table
|
||||
</button>
|
||||
<button id="db-view-add-btn" class="db-view-add" title="Add a view">
|
||||
{{ fd_icon("plus",14) }}
|
||||
</button>
|
||||
<div id="db-add-view-dropdown" class="db-dropdown" style="display:none">
|
||||
<div class="db-dropdown-header">Add a new view</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Table</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Board</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("list",14) }} List</div>
|
||||
<div class="db-dropdown-item">{{ fd_icon("calendar",14) }} Calendar</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-view-actions">
|
||||
<button id="db-filter-btn" class="db-btn-icon" title="Filter">{{ fd_icon("search",14) }}</button>
|
||||
<button id="db-new-btn" class="db-btn-new">
|
||||
<span class="db-new-icon">{{ fd_icon("plus",12) }}</span> New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Table -->
|
||||
<div id="db-table-wrapper" class="db-table-wrapper">
|
||||
<table id="db-table" class="db-table">
|
||||
<thead id="db-thead"></thead>
|
||||
<tbody id="db-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Property Modal -->
|
||||
<div id="db-prop-modal" class="db-modal-overlay" style="display:none">
|
||||
<div class="db-modal">
|
||||
<div class="db-modal-header">
|
||||
<h3>Add a property</h3>
|
||||
<button id="db-prop-modal-close">{{ fd_icon("x",14) }}</button>
|
||||
</div>
|
||||
<div class="db-modal-body">
|
||||
<div class="db-modal-field">
|
||||
<label>Name</label>
|
||||
<input id="db-prop-name-input" type="text" placeholder="Property name" />
|
||||
</div>
|
||||
<div class="db-modal-field">
|
||||
<label>Type</label>
|
||||
<select id="db-prop-type-select">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="select">Select</option>
|
||||
<option value="multi_select">Multi-select</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="db-modal-actions">
|
||||
<button id="db-prop-cancel" class="db-btn-cancel">Cancel</button>
|
||||
<button id="db-prop-create" class="db-btn-create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Header Context Menu -->
|
||||
<div id="db-col-menu" class="db-col-menu" style="display:none">
|
||||
<div class="db-col-menu-header">
|
||||
<span class="db-col-menu-icon" id="db-col-menu-icon">Aa</span>
|
||||
<span class="db-col-menu-name" id="db-col-menu-name">Name</span>
|
||||
<span class="db-col-menu-info" id="db-col-menu-info"></span>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-show-icon">
|
||||
<span>😊 Show page icon</span>
|
||||
<span class="db-col-menu-toggle on" id="db-col-icon-toggle">● ON</span>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-ai-autofill">
|
||||
<span>✨ AI Autofill</span>
|
||||
<span class="db-col-menu-hint">Now with agents</span>
|
||||
<span class="db-col-menu-arrow">›</span>
|
||||
</div>
|
||||
<div class="db-col-menu-sub" id="db-col-ai-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>✨ Basic</span></div>
|
||||
<div class="db-col-menu-subnote">Not available for this property type</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item sub-item"><span>🤖 Custom Agent</span><span class="db-col-menu-tag">New</span></div>
|
||||
<div class="db-col-menu-subnote">Uses advanced models to fill properties based on web search and your connected workspace.</div>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-filter"><span>≡ Filter</span></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-sort"><span>↕ Sort</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-sort-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>↑ Sort ascending</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>↓ Sort descending</span></div>
|
||||
</div>
|
||||
<div class="db-col-menu-item" id="db-col-group"><span>▦ Group</span></div>
|
||||
<div class="db-col-menu-item has-sub" id="db-col-calc"><span>Σ Calculate</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item checked"><span>None</span></div>
|
||||
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-count"><span>Count</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-count-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>Count all</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count values</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count unique values</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count empty</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Count not empty</span></div>
|
||||
</div>
|
||||
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-pct"><span>Percent</span><span class="db-col-menu-arrow">›</span></div>
|
||||
<div class="db-col-menu-sub" id="db-col-calc-pct-sub" style="display:none">
|
||||
<div class="db-col-menu-item sub-item"><span>Percent empty</span></div>
|
||||
<div class="db-col-menu-item sub-item"><span>Percent not empty</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-freeze"><span>🧊 Freeze</span></div>
|
||||
<div class="db-col-menu-item" id="db-col-wrap"><span>📝 Wrap content</span></div>
|
||||
<div class="db-col-menu-sep"></div>
|
||||
<div class="db-col-menu-item" id="db-col-insert-left"><span>├← Insert left</span></div>
|
||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Side Peek Panel for Database Pages -->
|
||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
|
||||
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
|
||||
</div>
|
||||
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,472 @@
|
||||
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
|
||||
<style>.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}</style>
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── SVG Outline Icons ──
|
||||
function propTypeSvg(type) {
|
||||
const s = 14;
|
||||
switch(type) {
|
||||
case 'text': case 'title':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||||
case 'number':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
|
||||
case 'select':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
|
||||
case 'multi_select':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
|
||||
case 'date':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
case 'checkbox':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
case 'url':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
case 'email':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
case 'phone':
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
||||
default:
|
||||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function cellValue(cpage, prop) {
|
||||
if (!cpage.property_values_json) return '';
|
||||
let pv;
|
||||
try { pv = typeof cpage.property_values_json === 'string' ? JSON.parse(cpage.property_values_json) : cpage.property_values_json; }
|
||||
catch(e) { return ''; }
|
||||
const val = pv[String(prop.id)];
|
||||
if (val === undefined || val === null) return prop.prop_type === 'title' ? (cpage.title || '') : '';
|
||||
if (Array.isArray(val)) return val.join(', ');
|
||||
return String(val);
|
||||
}
|
||||
|
||||
// ── DB Instance (one per container) ──
|
||||
function DBInstance(containerEl, collectionId, initialData) {
|
||||
const state = {
|
||||
properties: initialData ? initialData.properties : [],
|
||||
pages: initialData ? initialData.pages : [],
|
||||
editingCell: null,
|
||||
colMenuPropId: null,
|
||||
colMenuSubOpen: null,
|
||||
colMenuIconOn: true,
|
||||
colWidths: {}
|
||||
};
|
||||
|
||||
function getCsrf() { const m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
|
||||
|
||||
function render() {
|
||||
const props = state.properties;
|
||||
const pages = state.pages;
|
||||
|
||||
// Init col widths
|
||||
for (const p of props) { if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; }
|
||||
|
||||
// Build inner HTML
|
||||
let h = '<div class="db-view-bar" style="padding:4px 0 8px">';
|
||||
h += '<div class="db-view-tabs"><button class="db-view-tab active"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> Table</button></div>';
|
||||
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '</div>';
|
||||
|
||||
h += '<div class="db-table-wrapper"><table class="db-table">';
|
||||
// Header
|
||||
h += '<thead><tr class="db-header-row">';
|
||||
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
|
||||
for (const prop of props) {
|
||||
const w = state.colWidths[prop.id] || 200;
|
||||
h += '<th class="db-th db-th-property" style="min-width:'+w+'px;width:'+w+'px" data-prop-id="'+prop.id+'">';
|
||||
h += '<div class="db-th-content" data-prop-id="'+prop.id+'" data-prop-type="'+prop.prop_type+'" data-prop-name="'+escHtml(prop.name)+'">';
|
||||
h += '<span class="db-prop-type-icon">'+propTypeSvg(prop.prop_type)+'</span>';
|
||||
h += '<span class="db-prop-name db-col-title-btn">'+escHtml(prop.name)+'</span>';
|
||||
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
|
||||
}
|
||||
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
|
||||
h += '<th class="db-th" style="width:72px;min-width:72px"></th>';
|
||||
h += '</tr></thead>';
|
||||
|
||||
// Body
|
||||
h += '<tbody>';
|
||||
for (const cp of pages) {
|
||||
h += '<tr class="db-row" data-page-id="'+cp.id+'">';
|
||||
h += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
|
||||
for (const prop of props) {
|
||||
const val = cellValue(cp, prop) || '\u2014';
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
h += '<td class="db-td db-td-cell" style="min-width:'+(state.colWidths[prop.id]||200)+'px">';
|
||||
h += '<div class="db-cell-content" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
|
||||
h += '<span class="db-cell-value">'+escHtml(val)+'</span>';
|
||||
// OPEN button inline in first (title) column
|
||||
if (isNameCol) {
|
||||
h += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+cp.id+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
h += '</div></td>';
|
||||
}
|
||||
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
|
||||
h += '</tr>';
|
||||
}
|
||||
// Empty rows + New Page at bottom
|
||||
const emptyNeeded = Math.max(0, 4 - pages.length);
|
||||
for (let i = 0; i < emptyNeeded; i++) {
|
||||
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
|
||||
for (let j = 0; j < props.length; j++) h += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
|
||||
h += '<td class="db-td db-td-open"></td></tr>';
|
||||
}
|
||||
h += '<tr class="db-row db-row-new"><td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
|
||||
h += '<td class="db-td db-td-name" colspan="'+(Math.max(1,props.length)+1)+'"><button class="db-new-page-btn db-add-page-btn">New page</button></td></tr>';
|
||||
h += '</tbody></table></div>';
|
||||
|
||||
containerEl.innerHTML = h;
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// New page buttons
|
||||
containerEl.querySelectorAll('.db-add-page-btn').forEach(b => b.onclick = addNewPage);
|
||||
// Cell edit
|
||||
containerEl.querySelectorAll('.db-cell-content').forEach(el => {
|
||||
el.onclick = function(e) {
|
||||
if (state.editingCell || e.target.closest('.db-open-btn')) return;
|
||||
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
|
||||
};
|
||||
});
|
||||
// Open buttons
|
||||
containerEl.querySelectorAll('.db-open-page-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
openPageInSidePeek(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// AI fill properties
|
||||
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
aiFillRow(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// Hover
|
||||
containerEl.querySelectorAll('.db-row').forEach(row => {
|
||||
row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); };
|
||||
row.onmouseleave = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'hidden'); };
|
||||
});
|
||||
// Header context menu
|
||||
containerEl.querySelectorAll('.db-th-content').forEach(el => {
|
||||
el.onclick = function(e) { e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
|
||||
});
|
||||
// Column resize
|
||||
containerEl.querySelectorAll('.db-col-resize-handle').forEach(handle => {
|
||||
handle.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const propId = parseInt(this.dataset.propId);
|
||||
const th = this.parentElement;
|
||||
const thLeft = th.getBoundingClientRect().left;
|
||||
function onMove(ev) {
|
||||
const newW = Math.max(60, ev.clientX - thLeft);
|
||||
state.colWidths[propId] = newW;
|
||||
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
|
||||
const colIdx = state.properties.findIndex(p => p.id === propId);
|
||||
if (colIdx >= 0) {
|
||||
containerEl.querySelectorAll('.db-row').forEach(row => {
|
||||
const cells = row.querySelectorAll('.db-td-cell');
|
||||
if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px';
|
||||
});
|
||||
}
|
||||
}
|
||||
function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; }
|
||||
document.body.style.cursor='col-resize'; document.body.style.userSelect='none';
|
||||
document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp);
|
||||
};
|
||||
});
|
||||
// Add property modal bind
|
||||
containerEl.querySelector('.db-show-prop-modal-btn').onclick = function() { showPropModal(); };
|
||||
}
|
||||
|
||||
function startEditCell(pageId, propId, cellEl) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
const prop = state.properties.find(p => p.id === propId);
|
||||
if (!cp || !prop) return;
|
||||
const val = cellValue(cp, prop);
|
||||
const input = document.createElement('input');
|
||||
input.type='text'; input.className='db-cell-input'; input.value=val;
|
||||
cellEl.innerHTML=''; cellEl.appendChild(input); cellEl.classList.add('db-cell-editing'); input.focus();
|
||||
state.editingCell = {pageId,propId,input,cellEl};
|
||||
input.onkeydown = function(e) { if(e.key==='Enter') commitEditCell(); else if(e.key==='Escape') cancelEditCell(); };
|
||||
input.onblur = function() { commitEditCell(); };
|
||||
}
|
||||
|
||||
function commitEditCell() {
|
||||
if(!state.editingCell) return;
|
||||
const {pageId,propId,input,cellEl}=state.editingCell; state.editingCell=null;
|
||||
const val=input.value;
|
||||
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
|
||||
if(!cp||!prop) return;
|
||||
let pv; try{pv=typeof cp.property_values_json==='string'?JSON.parse(cp.property_values_json):cp.property_values_json}catch(e){pv={};}
|
||||
pv[String(propId)]=val; cp.property_values_json=pv;
|
||||
if(prop.prop_type==='title') cp.title=val;
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
|
||||
if (isNameCol) {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})})
|
||||
.then(function(r){
|
||||
if(!r.ok){
|
||||
return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
|
||||
}
|
||||
cellEl.classList.remove('db-cell-invalid');
|
||||
return r.json();
|
||||
})
|
||||
.catch(function(err){
|
||||
// Validation failed (400) → show message + revert the cell.
|
||||
const msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
|
||||
if(err && err.status===400){
|
||||
cellEl.classList.add('db-cell-invalid');
|
||||
cellEl.title = msg;
|
||||
}
|
||||
if(typeof window.showToast==='function') window.showToast('⚠ '+msg,'error');
|
||||
else alert('⚠ '+msg);
|
||||
// Revert to the stored value.
|
||||
const stored = pv[String(propId)];
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(stored||'\u2014')+'</span>';
|
||||
if (prop.prop_type === 'title') {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
if(!state.editingCell) return;
|
||||
const {pageId,propId,cellEl}=state.editingCell; state.editingCell=null;
|
||||
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
|
||||
if(!cp||!prop) return;
|
||||
const val=cellValue(cp,prop);
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
const isNameCol = prop.prop_type === 'title';
|
||||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
|
||||
if (isNameCol) {
|
||||
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||||
}
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
}
|
||||
|
||||
// ── v5.9.0: AI property suggestions ──
|
||||
async function aiFillRow(pageId) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
if (!cp) return;
|
||||
const props = state.properties.filter(p => p.prop_type !== 'title');
|
||||
if (!props.length) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: cp.title || '',
|
||||
content: cp.content || '',
|
||||
properties: props.map(p => ({ name: p.name, type: p.prop_type }))
|
||||
};
|
||||
if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…');
|
||||
try {
|
||||
const r = await fetch('/api/agent/writing/properties', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d || !d.ok) {
|
||||
if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération');
|
||||
return;
|
||||
}
|
||||
let pv;
|
||||
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); }
|
||||
catch (e) { pv = {}; }
|
||||
let filled = 0;
|
||||
for (const p of props) {
|
||||
const v = d.suggestions ? d.suggestions[p.name] : undefined;
|
||||
if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; }
|
||||
}
|
||||
cp.property_values_json = pv;
|
||||
await fetch('/db/pages/' + pageId + '/api', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ title: cp.title, properties: pv })
|
||||
});
|
||||
render();
|
||||
if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
|
||||
} catch (e) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Erreur IA');
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewPage() {
|
||||
try {
|
||||
const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})});
|
||||
const d = await r.json();
|
||||
if(d.id) { state.pages.push({id:d.id,title:'',position:state.pages.length,icon:'file',collection_id:collectionId,property_values_json:{}}); render(); }
|
||||
} catch(e) { console.error('[DB] addNewPage error:', e); }
|
||||
}
|
||||
|
||||
// ── Property modal (inline, self-contained) ──
|
||||
function showPropModal() {
|
||||
// Remove any existing inline modal
|
||||
var old = containerEl.querySelector('.db-prop-modal-inline');
|
||||
if (old) old.remove();
|
||||
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'db-prop-modal-inline';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center';
|
||||
modal.innerHTML = '<div class="db-modal" style="background:var(--bg-modal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.15);width:420px;max-width:90vw;overflow:hidden">'+
|
||||
'<div class="db-modal-header"><h3>Add a property</h3><button class="db-prop-modal-close-btn" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;color:var(--text-dim);font-size:18px">×</button></div>'+
|
||||
'<div class="db-modal-body" style="padding:16px">'+
|
||||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
|
||||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="date">Date</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option></select></div>'+
|
||||
'<div style="display:flex;gap:16px;margin-bottom:12px">'+
|
||||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label>'+
|
||||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label>'+
|
||||
'</div>'+
|
||||
'<div style="display:flex;gap:10px;margin-bottom:12px">'+
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||||
'</div>'+
|
||||
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
|
||||
var nameInput = modal.querySelector('.db-prop-name-inline');
|
||||
nameInput.focus();
|
||||
|
||||
modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); };
|
||||
modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); };
|
||||
modal.onclick = function(e){ if(e.target===modal) modal.remove(); };
|
||||
|
||||
modal.querySelector('.db-prop-create-inline').onclick = async function(){
|
||||
var name = nameInput.value.trim();
|
||||
if(!name) return;
|
||||
var type = modal.querySelector('.db-prop-type-inline').value;
|
||||
var validation = {};
|
||||
if(modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
|
||||
if(modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
|
||||
var min = modal.querySelector('.db-prop-min-inline').value;
|
||||
var max = modal.querySelector('.db-prop-max-inline').value;
|
||||
if(min!=='') validation.min = Number(min);
|
||||
if(max!=='') validation.max = Number(max);
|
||||
try {
|
||||
var r = await fetch('/db/'+collectionId+'/properties/api',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||||
body:JSON.stringify({name:name,prop_type:type,validation:validation})
|
||||
});
|
||||
var d = await r.json();
|
||||
if(d.id){ state.properties.push(d); render(); modal.remove(); }
|
||||
} catch(e){ console.error('[DB] createProperty error:',e); }
|
||||
};
|
||||
nameInput.onkeydown = function(e){ if(e.key==='Enter') modal.querySelector('.db-prop-create-inline').click(); };
|
||||
}
|
||||
|
||||
// ── Column context menu (inline) ──
|
||||
function showColMenu(propId, propType, propName, anchor) {
|
||||
// Remove any existing
|
||||
var old = document.querySelector('.db-col-menu-inline');
|
||||
if (old) old.remove();
|
||||
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'db-col-menu-inline';
|
||||
menu.style.cssText = 'position:fixed;min-width:240px;max-width:320px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.14);z-index:800;padding:4px 0;font-size:13px;color:var(--text-primary)';
|
||||
var rect = anchor.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px';
|
||||
|
||||
menu.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;font-weight:600">'+
|
||||
'<span>'+propTypeSvg(propType)+'</span><span>'+escHtml(propName)+'</span><span style="font-size:11px;color:var(--text-dim)">ⓘ</span></div>'+
|
||||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">▦ Group</div>'+
|
||||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
|
||||
'<div style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
setTimeout(() => document.addEventListener('click', function onDoc(e) {
|
||||
if(!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click',onDoc); }
|
||||
}, {once:true}), 0);
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
if (!initialData) {
|
||||
fetch('/api/collections/'+collectionId+'/table-data').then(r=>r.json()).then(d => {
|
||||
state.properties = d.properties||[]; state.pages = d.pages||[]; render();
|
||||
}).catch(e=>console.error('[DB] load error:',e));
|
||||
} else { render(); }
|
||||
}
|
||||
|
||||
// ── Global API ──
|
||||
window.FlowDeckDB = {
|
||||
/** Render a database table into a container element. */
|
||||
renderInto: function(containerEl, collectionId, initialData) {
|
||||
new DBInstance(containerEl, collectionId, initialData);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Full-page init (when page_editor_collection.html is used) ──
|
||||
const PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
|
||||
if (PAGE_COLLECTION_ID > 0) {
|
||||
const container = document.getElementById('db-table-container');
|
||||
if (container) {
|
||||
{% if collection_data %}
|
||||
new DBInstance(container, PAGE_COLLECTION_ID, {
|
||||
properties: {{ collection_data.properties | tojson }},
|
||||
pages: {{ collection_data.pages | tojson }}
|
||||
});
|
||||
{% else %}
|
||||
new DBInstance(container, PAGE_COLLECTION_ID, null);
|
||||
{% endif %}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global Side Peek for Database Pages (auto-creates panel) ──
|
||||
function openPageInSidePeek(pageId) {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (!panel) {
|
||||
// Create panel dynamically
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'db-side-peek';
|
||||
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
|
||||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||||
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
|
||||
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
|
||||
'</div>'+
|
||||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||||
document.body.appendChild(panel);
|
||||
|
||||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||||
document.getElementById('db-side-peek-full').onclick = function(){
|
||||
var ifr = document.getElementById('db-side-peek-iframe');
|
||||
if(ifr&&ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||||
};
|
||||
// Resize
|
||||
var rh = document.getElementById('db-side-peek-resize');
|
||||
rh.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var sx = e.clientX, sw = panel.offsetWidth;
|
||||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||||
document.body.style.cursor='col-resize';
|
||||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||||
};
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
|
||||
}
|
||||
function closeSidePeek() {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (panel) { panel.style.display = 'none'; }
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = ''; }
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@
|
||||
#}
|
||||
{% set ns = namespace(items=[]) %}
|
||||
{% if not hide_home %}
|
||||
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "🏠", "menu": false, "home": true}] %}
|
||||
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "home", "menu": false, "home": true}] %}
|
||||
{% endif %}
|
||||
{% if breadcrumb_items %}
|
||||
{% for item in breadcrumb_items %}
|
||||
@@ -36,7 +36,8 @@
|
||||
<header class="topbar unified-header">
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
@@ -51,7 +52,7 @@
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
{% if page_icon %}
|
||||
<span class="header-page-icon">{{ page_icon }}</span>
|
||||
<span class="header-page-icon">{{ fd_page_icon(page_icon, 18) }}</span>
|
||||
{% endif %}
|
||||
|
||||
<template x-for="(crumb, di) in display" :key="di">
|
||||
@@ -67,7 +68,7 @@
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||
<span class="fd-nav-ico" x-text="h.icon || '📄'"></span>
|
||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="h.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,7 +111,7 @@
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@click.stop="go(item.url); closeAll()">
|
||||
<span class="fd-nav-ico" x-text="item.icon || '📄'"></span>
|
||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="item.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
|
||||
@@ -118,7 +119,7 @@
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||
<span class="fd-nav-ico" x-text="s.icon || '📄'"></span>
|
||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="s.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||
</div>
|
||||
@@ -136,11 +137,12 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-right header-actions">
|
||||
{% include '_notification_bell.html' %}
|
||||
{{ right_actions|safe if right_actions else '' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
@@ -246,7 +248,11 @@ document.addEventListener('alpine:init', function () {
|
||||
this.activeItems = [];
|
||||
},
|
||||
|
||||
go: function (url) { if (url) window.location.href = url; }
|
||||
go: function (url) {
|
||||
if (!url) return;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
{###############################################################################
|
||||
_icon_picker.html — Sélecteur d'icône/emoji façon Notion (v5.6.0)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Popover global (inclus par base.html) utilisé par :
|
||||
• le menu contextuel (library / local-workspace) via fdCtx.setIcon
|
||||
• l'éditeur de page (clic sur l'icône du titre)
|
||||
Expose le store Alpine `fdIconPicker` + `window.FDIconPicker`.
|
||||
Onglets Emoji / Icons / Upload, recherche, aléatoire, teintes de peau,
|
||||
récents (localStorage) et emojis personnalisés du workspace (API).
|
||||
############################################################################}
|
||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||
|
||||
<script data-cfasync="false">
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(function () {
|
||||
if (window.__fdIconPickerRegistered) return;
|
||||
window.__fdIconPickerRegistered = true;
|
||||
|
||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
var TONEABLE = {};
|
||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
||||
|
||||
var CATS = [
|
||||
{ id: 'people', label: 'People', items: [
|
||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
||||
]},
|
||||
{ id: 'nature', label: 'Nature', items: [
|
||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
||||
]},
|
||||
{ id: 'food', label: 'Food', items: [
|
||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
||||
]},
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
||||
]},
|
||||
{ id: 'travel', label: 'Travel', items: [
|
||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
||||
]},
|
||||
{ id: 'objects', label: 'Objects', items: [
|
||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
||||
]},
|
||||
{ id: 'symbols', label: 'Symbols', items: [
|
||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
||||
]},
|
||||
{ id: 'flags', label: 'Flags', items: [
|
||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
||||
]}
|
||||
];
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
cats: CATS,
|
||||
|
||||
openFor(opts) {
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
this._cx = (opts.x != null) ? opts.x : 240;
|
||||
this._cy = (opts.y != null) ? opts.y : 200;
|
||||
this.x = this._cx;
|
||||
this.y = this._cy;
|
||||
this.open = true;
|
||||
var self = this;
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-icon-picker');
|
||||
if (!el) return;
|
||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = self._cx, ny = self._cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
this.loadRecent();
|
||||
this.loadCustom();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
this.onRemove = null;
|
||||
},
|
||||
|
||||
matches(name) {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().indexOf(q) >= 0;
|
||||
},
|
||||
|
||||
items() {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
var out = [];
|
||||
this.cats.forEach(function (c) {
|
||||
c.items.forEach(function (it) {
|
||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (this.category === 'recent') return this.recent;
|
||||
var found = [];
|
||||
for (var i = 0; i < this.cats.length; i++) {
|
||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
withTone(e) {
|
||||
if (!this.skinTone) return e;
|
||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
||||
return e;
|
||||
},
|
||||
|
||||
pick(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return;
|
||||
this.pushRecent(v);
|
||||
var fn = this.onPick;
|
||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
this.close();
|
||||
},
|
||||
|
||||
random() {
|
||||
var pool = [];
|
||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
||||
if (!pool.length) return;
|
||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
||||
},
|
||||
|
||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
var r = this.recent.filter(function (x) { return x !== e; });
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
try {
|
||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
this.customModal = true;
|
||||
this.customName = '';
|
||||
this.customPreview = '';
|
||||
this.customFile = null;
|
||||
this.tab = 'upload';
|
||||
},
|
||||
closeCustomModal() { this.customModal = false; },
|
||||
onCustomFile(ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
this.customFile = f;
|
||||
var self = this;
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { self.customPreview = fr.result; };
|
||||
fr.readAsDataURL(f);
|
||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
||||
},
|
||||
async saveCustom() {
|
||||
if (!this.customFile || this.customBusy) return;
|
||||
this.customBusy = true;
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
this.custom.unshift(d.emoji);
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.FDIconPicker = {
|
||||
openFor: function (opts) {
|
||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
||||
if (s) s.openFor(opts);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tab.active{background:var(--bg-hover);color:var(--text-primary);font-weight:600;}
|
||||
.fd-icon-picker .fdip-remove{margin-left:auto;background:transparent;border:none;color:var(--text-secondary);font-size:12px;padding:5px 8px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-remove:hover{background:var(--bg-hover);color:var(--danger);}
|
||||
.fd-icon-picker .fdip-tools{display:flex;align-items:center;gap:6px;padding:8px 10px;}
|
||||
.fd-icon-picker .fdip-search{flex:1;min-width:0;font-size:13px;padding:6px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);outline:none;}
|
||||
.fd-icon-picker .fdip-search:focus{border-color:var(--accent);}
|
||||
.fd-icon-picker .fdip-tool{width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:15px;color:var(--text-primary);}
|
||||
.fd-icon-picker .fdip-tool:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tone-wrap{position:relative;}
|
||||
.fd-icon-picker .fdip-tone-menu{position:absolute;right:0;top:34px;display:flex;gap:2px;padding:6px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);z-index:5;}
|
||||
.fd-icon-picker .fdip-tone{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:16px;}
|
||||
.fd-icon-picker .fdip-tone:hover,.fd-icon-picker .fdip-tone.selected{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-body{flex:1;overflow-y:auto;padding:0 10px 6px;}
|
||||
.fd-icon-picker .fdip-section{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);padding:6px 2px 2px;}
|
||||
.fd-icon-picker .fdip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;}
|
||||
.fd-icon-picker .fdip-emoji,.fd-icon-picker .fdip-icon{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:21px;line-height:1;color:var(--text-primary);position:relative;}
|
||||
.fd-icon-picker .fdip-emoji:hover,.fd-icon-picker .fdip-icon:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-custom img{width:24px;height:24px;object-fit:contain;}
|
||||
.fd-icon-picker .fdip-del{position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%;background:var(--danger);color:#fff;font-size:10px;line-height:14px;text-align:center;display:none;}
|
||||
.fd-icon-picker .fdip-custom:hover .fdip-del{display:block;}
|
||||
.fd-icon-picker .fdip-empty{color:var(--text-dim);font-size:12px;text-align:center;padding:24px 0;}
|
||||
.fd-icon-picker .fdip-cats{display:flex;align-items:center;gap:2px;padding:6px 10px;border-top:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-cat{flex:1;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;color:var(--text-secondary);}
|
||||
.fd-icon-picker .fdip-cat:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-cat.active{background:var(--bg-hover);color:var(--accent);}
|
||||
.fd-icon-picker .fdip-cat svg{width:16px;height:16px;}
|
||||
.fdip-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2300;display:flex;align-items:center;justify-content:center;}
|
||||
.fdip-modal{width:420px;max-width:92vw;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:20px;box-shadow:0 16px 48px rgba(0,0,0,.35);}
|
||||
.fdip-modal-title{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:6px;}
|
||||
.fdip-modal-desc{font-size:13px;color:var(--text-secondary);margin-bottom:16px;}
|
||||
.fdip-upload-btn{display:inline-block;padding:8px 14px;font-size:13px;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--text-primary);background:var(--bg-secondary);}
|
||||
.fdip-upload-btn:hover{background:var(--bg-hover);}
|
||||
.fdip-preview-boxes{display:flex;gap:10px;margin:14px 0;}
|
||||
.fdip-preview{width:64px;height:64px;border-radius:8px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);}
|
||||
.fdip-preview.dark{background:#111;}
|
||||
.fdip-preview.light{background:#fff;}
|
||||
.fdip-preview img{max-width:44px;max-height:44px;object-fit:contain;}
|
||||
.fdip-modal-label{display:block;font-size:12px;color:var(--text-secondary);margin:8px 0 4px;}
|
||||
.fdip-modal-input{width:100%;font-size:14px;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;box-sizing:border-box;}
|
||||
.fdip-modal-input:focus{border-color:var(--accent);}
|
||||
.fdip-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;}
|
||||
.fdip-modal-actions button{padding:8px 16px;font-size:13px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);}
|
||||
.fdip-modal-actions button.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
|
||||
.fdip-modal-actions button:disabled{opacity:.5;cursor:default;}
|
||||
</style>
|
||||
|
||||
<div x-data>
|
||||
<div class="fd-icon-picker" x-show="$store.fdIconPicker.open" x-cloak
|
||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
||||
<div class="fdip-tabs">
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
||||
<button class="fdip-remove" @click="$store.fdIconPicker.remove()">Remove</button>
|
||||
</div>
|
||||
|
||||
<div class="fdip-tools" x-show="$store.fdIconPicker.tab !== 'upload'">
|
||||
<input class="fdip-search" placeholder="Filter..." x-model="$store.fdIconPicker.query">
|
||||
<button class="fdip-tool" title="Random" @click="$store.fdIconPicker.random()">🎲</button>
|
||||
<div class="fdip-tone-wrap" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-tool" title="Select skin tone" @click.stop="$store.fdIconPicker.toneOpen = !$store.fdIconPicker.toneOpen">👋</button>
|
||||
<div class="fdip-tone-menu" x-show="$store.fdIconPicker.toneOpen" @click.outside="$store.fdIconPicker.toneOpen = false">
|
||||
<template x-for="(t, ti) in ['👋','👋🏻','👋🏼','👋🏽','👋🏾','👋🏿']" :key="ti">
|
||||
<button class="fdip-tone" :class="{ selected: $store.fdIconPicker.skinTone === ti }" @click="$store.fdIconPicker.setTone(ti)" x-text="t"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-body">
|
||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||
<div>
|
||||
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.items().length">No emoji found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'icons'">
|
||||
<div>
|
||||
<div class="fdip-grid">
|
||||
{% for name in picker_icons %}
|
||||
<button class="fdip-icon" x-show="$store.fdIconPicker.matches('{{ name }}')" @click="$store.fdIconPicker.pick('{{ name }}')" title="{{ name }}">{{ fd_icon(name, 20) }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'upload'">
|
||||
<div>
|
||||
<div class="fdip-custom-head" style="display:flex;align-items:center;justify-content:space-between;padding:6px 2px;">
|
||||
<span class="fdip-section" style="padding:0;">Custom emojis</span>
|
||||
<button class="fdip-tool" style="width:auto;padding:0 10px;" @click="$store.fdIconPicker.openCustomModal()">+ Add</button>
|
||||
</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="ce in $store.fdIconPicker.custom" :key="ce.id">
|
||||
<button class="fdip-emoji fdip-custom" @click="$store.fdIconPicker.pick(ce.url)" :title="ce.name">
|
||||
<img :src="ce.url" :alt="ce.name">
|
||||
<span class="fdip-del" @click.stop="$store.fdIconPicker.deleteCustom(ce.id)">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.custom.length">No custom emoji yet</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="fdip-cats" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'recent' }" title="Recent" @click="$store.fdIconPicker.setCategory('recent')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'people' }" title="People" @click="$store.fdIconPicker.setCategory('people')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14s1.3 2 3.5 2 3.5-2 3.5-2"/><line x1="9" y1="9.5" x2="9.01" y2="9.5"/><line x1="15" y1="9.5" x2="15.01" y2="9.5"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'nature' }" title="Nature" @click="$store.fdIconPicker.setCategory('nature')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 4 13c0-6 7-10 16-10 0 9-4 16-10 16z"/><path d="M4 21c2-4 5-7 9-9"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'food' }" title="Food" @click="$store.fdIconPicker.setCategory('food')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21l6-6"/><path d="M14 3s5 1 7 7c-4 4-8 3-10 1s-1-6 3-8z"/><path d="M9 12l-4 4"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'activity' }" title="Activity" @click="$store.fdIconPicker.setCategory('activity')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 11V6a2 2 0 0 1 4 0v5"/><path d="M10 11V4a2 2 0 0 1 4 0v7"/><path d="M14 11V6a2 2 0 0 1 4 0v9a7 7 0 0 1-7 7H9a6 6 0 0 1-6-6v-3a2 2 0 0 1 3-1.7"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'travel' }" title="Travel" @click="$store.fdIconPicker.setCategory('travel')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 16l20-6-3-3-7 1-5-5-2 1 3 5-4 1-2-2-1 1z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'objects' }" title="Objects" @click="$store.fdIconPicker.setCategory('objects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a6 6 0 0 0-4 10.5c.8.7 1 1.5 1 2.5h6c0-1 .2-1.8 1-2.5A6 6 0 0 0 12 2z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'symbols' }" title="Symbols" @click="$store.fdIconPicker.setCategory('symbols')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'flags' }" title="Flags" @click="$store.fdIconPicker.setCategory('flags')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/></svg></button>
|
||||
<button class="fdip-cat" title="Add custom emoji" @click="$store.fdIconPicker.openCustomModal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-modal-overlay" x-show="$store.fdIconPicker.customModal" x-cloak @click.self="$store.fdIconPicker.closeCustomModal()">
|
||||
<div class="fdip-modal">
|
||||
<div class="fdip-modal-title">Add custom emoji</div>
|
||||
<div class="fdip-modal-desc">Custom emoji can be used by anyone in your workspace.</div>
|
||||
<label class="fdip-upload-btn">
|
||||
<span x-show="!$store.fdIconPicker.customPreview">Upload an image</span>
|
||||
<span x-show="$store.fdIconPicker.customPreview">Replace</span>
|
||||
<input type="file" accept="image/*" @change="$store.fdIconPicker.onCustomFile($event)" style="display:none">
|
||||
</label>
|
||||
<div class="fdip-preview-boxes" x-show="$store.fdIconPicker.customPreview">
|
||||
<div class="fdip-preview dark"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
<div class="fdip-preview light"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
</div>
|
||||
<label class="fdip-modal-label">Emoji name</label>
|
||||
<input class="fdip-modal-input" x-model="$store.fdIconPicker.customName" placeholder="pushup">
|
||||
<div class="fdip-modal-actions">
|
||||
<button @click="$store.fdIconPicker.closeCustomModal()">Cancel</button>
|
||||
<button class="primary" :disabled="$store.fdIconPicker.customBusy || !$store.fdIconPicker.customFile" @click="$store.fdIconPicker.saveCustom()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
{# FlowDeck — Outline SVG Icon Set (v4.0+)
|
||||
Usage: {{ fd_icon('folder') }} {{ fd_icon('star', 16) }}
|
||||
All icons use stroke="currentColor" for theme compatibility.
|
||||
#}
|
||||
{%- macro fd_icon(name, size=18) -%}
|
||||
{%- set s = size|string -%}
|
||||
{%- if name == 'folder' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
{%- elif name == 'file' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{%- elif name == 'calendar' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{%- elif name == 'clock' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{%- elif name == 'star' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
{%- elif name == 'bot' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
|
||||
{%- elif name == 'users' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
{%- elif name == 'globe' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
{%- elif name == 'lock' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
{%- elif name == 'book' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
{%- elif name == 'check-square' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
{%- elif name == 'trash' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
{%- elif name == 'help-circle' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{%- elif name == 'settings' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{%- elif name == 'refresh' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
{%- elif name == 'log-out' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{%- elif name == 'message-square' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
{%- elif name == 'home' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
{%- elif name == 'search' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
{%- elif name == 'link' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
{%- elif name == 'plus' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{%- elif name == 'bell' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
{%- elif name == 'image' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
{%- elif name == 'download' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{%- elif name == 'chevron-left' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
{%- elif name == 'chevron-right' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
{%- elif name == 'list' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
{%- elif name == 'bar-chart' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
{%- elif name == 'grid' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
{%- elif name == 'align-left' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
|
||||
{%- elif name == 'corner-down-right' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>
|
||||
{%- elif name == 'copy' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
{%- elif name == 'chevron-down' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
{%- elif name == 'key' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
{%- elif name == 'inbox' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
{%- elif name == 'edit' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
{%- elif name == 'copy' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
{%- elif name == 'eye-off' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
{%- elif name == 'eye' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{%- elif name == 'share' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
{%- elif name == 'more-horizontal' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
{%- elif name == 'x' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{%- elif name == 'paperclip' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{%- elif name == 'external-link' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
{%- elif name == 'sparkles' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/></svg>
|
||||
{%- elif name == 'lightbulb' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
|
||||
{%- elif name == 'tag' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
{%- elif name == 'file-text' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
{%- elif name == 'save' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
{%- elif name == 'upload' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
{%- elif name == 'trending-up' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
|
||||
{%- elif name == 'zap' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
{%- elif name == 'alert-triangle' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{%- elif name == 'user' -%}
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{#- Renders a page_icon value: custom-emoji image URL, known icon name, or emoji. -#}
|
||||
{%- macro fd_page_icon(value, size=18) -%}
|
||||
{%- set v = (value or '')|string|trim -%}
|
||||
{%- if v.startswith('/') or v.startswith('http') -%}
|
||||
<img src="{{ v }}" alt="" style="width:{{ size }}px;height:{{ size }}px;object-fit:contain;vertical-align:middle;display:inline-block;">
|
||||
{%- elif v in ("folder","file","calendar","clock","star","bot","users","globe","lock","book","check-square","trash","help-circle","settings","refresh","log-out","message-square","home","search","link","plus","bell","image","download","list","bar-chart","grid","align-left","corner-down-right","copy","key","inbox","edit","eye","share","x","paperclip","external-link","sparkles","lightbulb","tag","file-text","save","upload","trending-up","zap","alert-triangle","user") -%}
|
||||
{{ fd_icon(v, size) }}
|
||||
{%- else -%}
|
||||
{{ v }}
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
@@ -0,0 +1,134 @@
|
||||
{# ── Notification bell + dropdown (v4.9.0) ──
|
||||
Self-contained Alpine component. Polls unread count, opens a panel of
|
||||
notifications, marks as read. Only rendered for authenticated users. #}
|
||||
{% if user and user.get('id') %}
|
||||
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
|
||||
@click.outside="open=false" style="position:relative;display:inline-flex;">
|
||||
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
|
||||
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
|
||||
{{ fd_icon("bell", 16) }}
|
||||
<span x-show="unread > 0" x-cloak
|
||||
class="fd-notif-badge"
|
||||
x-text="unread > 99 ? '99+' : unread"
|
||||
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
|
||||
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
|
||||
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak x-transition
|
||||
class="fd-notif-panel"
|
||||
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
|
||||
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
|
||||
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
|
||||
<div class="fd-notif-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Notifications</span>
|
||||
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
|
||||
style="font-size:12px;cursor:pointer;">Mark all read</button>
|
||||
</div>
|
||||
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<a :href="n.url || '#'" @click.prevent="openItem(n)"
|
||||
class="fd-notif-item"
|
||||
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
|
||||
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
|
||||
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
|
||||
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-weight:700;font-size:14px;color:#fff;"
|
||||
:style="{ background: n.actor_color || '#3A3A3A' }">
|
||||
<template x-if="n.actor_avatar">
|
||||
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
|
||||
</template>
|
||||
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
|
||||
x-text="n.message"></div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="!loading && items.length === 0"
|
||||
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
|
||||
You're all caught up 🎉
|
||||
</div>
|
||||
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
Alpine.data('fdNotifications', function () {
|
||||
return {
|
||||
open: false, items: [], unread: 0, loading: false, _timer: null,
|
||||
init() {
|
||||
this.load();
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
destroy() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
|
||||
if (isNaN(t.getTime())) t = new Date(s);
|
||||
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
csrf() {
|
||||
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
},
|
||||
refreshCount() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { self.unread = d.unread || 0; })
|
||||
.catch(function () {});
|
||||
},
|
||||
load() {
|
||||
var self = this;
|
||||
this.loading = true;
|
||||
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
self.items = d.notifications || [];
|
||||
self.unread = d.unread || 0;
|
||||
self.loading = false;
|
||||
})
|
||||
.catch(function () { self.loading = false; });
|
||||
},
|
||||
openItem(n) {
|
||||
if (!n.is_read) {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({ id: n.id })
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
if (n.url) window.location.href = n.url;
|
||||
this.open = false;
|
||||
},
|
||||
markAllRead() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
|
||||
body: JSON.stringify({})
|
||||
}).then(function () { self.refreshCount(); self.load(); });
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,833 @@
|
||||
<div
|
||||
class="page-editor-wrapper"
|
||||
x-data="editorState()"
|
||||
x-init="loadCoverIcon()"
|
||||
>
|
||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
<div
|
||||
class="share-dialog"
|
||||
x-show="shareOpen"
|
||||
@click.outside="!_justOpened && (shareOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
>
|
||||
<!-- Header with tabs -->
|
||||
<div class="sd-header">
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'share' }"
|
||||
@click="shareTab = 'share'"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'publish' }"
|
||||
@click="shareTab = 'publish'"
|
||||
>
|
||||
Publish
|
||||
<span class="sd-badge" x-show="pagePublished"></span>
|
||||
</button>
|
||||
<button class="sd-close" @click="shareOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-wrap">
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="text"
|
||||
class="sd-input"
|
||||
x-ref="inviteInput"
|
||||
placeholder="Add people, emails or type a name..."
|
||||
x-model="inviteEmail"
|
||||
@input="inviteInput()"
|
||||
@focus="inviteInput()"
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
<!-- Existing users autocomplete -->
|
||||
<div
|
||||
class="sd-user-suggest"
|
||||
x-ref="inviteSuggest"
|
||||
x-show="inviteSuggestOpen"
|
||||
@click.outside="inviteSuggestOpen = false"
|
||||
x-transition.opacity
|
||||
>
|
||||
<template x-for="(u, i) in inviteUsers" :key="u.id">
|
||||
<div
|
||||
class="sd-user-item"
|
||||
:class="{ active: inviteSel === i }"
|
||||
@mouseenter="inviteSel = i"
|
||||
@click="pickInviteUser(u)"
|
||||
>
|
||||
<div
|
||||
class="sd-user-avatar"
|
||||
:style="{ background: u.avatar_color || '#3A3A3A' }"
|
||||
>
|
||||
<img
|
||||
x-show="u.avatar_url"
|
||||
:src="u.avatar_url"
|
||||
:alt="u.login"
|
||||
style="width:100%;height:100%;border-radius:50%;object-fit:cover;"
|
||||
/>
|
||||
<span
|
||||
x-show="!u.avatar_url"
|
||||
x-text="((u.full_name || u.login || '?').charAt(0).toUpperCase())"
|
||||
></span>
|
||||
</div>
|
||||
<div class="sd-user-meta">
|
||||
<div class="sd-user-login" x-text="u.login"></div>
|
||||
<div class="sd-user-name" x-text="u.full_name"></div>
|
||||
</div>
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
<div class="sd-context-card" x-show="false">
|
||||
Share settings on this page are unlinked from parent
|
||||
page
|
||||
</div>
|
||||
|
||||
<!-- Participants list -->
|
||||
<div class="sd-participants" x-show="accessList.length">
|
||||
<template x-for="a in accessList" :key="a.id">
|
||||
{% raw %}
|
||||
<div class="sd-participant">
|
||||
<div class="sd-participant-info">
|
||||
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
||||
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
<div class="sd-avatar" x-show="!a.user_avatar_url"
|
||||
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.user_full_name || a.user_login || a.shared_with_email"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.shared_with_email || a.user_login || ''"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="sd-perm-btn"
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'edit' ? 'Full access' : a.permission === 'comment' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
<div
|
||||
class="sd-perm-menu"
|
||||
x-show="a.permOpen"
|
||||
@click.outside="a.permOpen = false"
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Edit, suggest and comment</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Suggest and comment only</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Read only</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeShare(a.id); a.permOpen=false"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- General Access -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">General access</div>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
class="sd-access-btn"
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<span
|
||||
x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
|
||||
></span>
|
||||
<span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="sd-access-menu"
|
||||
x-show="accessMenuOpen"
|
||||
@click.outside="accessMenuOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Can view the page</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public permission when link is open -->
|
||||
<div
|
||||
x-show="generalAccess === 'anyone'"
|
||||
style="
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
"
|
||||
>Anyone with the link</span
|
||||
>
|
||||
<select class="sd-select-sm" x-model="publicPerm">
|
||||
<option value="viewer">Can view</option>
|
||||
<option value="commenter">Can comment</option>
|
||||
<option value="editor">Can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- v5.5.0: Cover & Icon -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">{{ fd_icon('image',14) }} Cover & Icon</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="sd-btn-secondary" @click="toggleCover()">
|
||||
<span x-show="!coverOpen">{{ fd_icon('image',14) }} Cover</span>
|
||||
<span x-show="coverOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
<button class="sd-btn-secondary" @click="toggleIcon()">
|
||||
<span x-show="!iconOpen">{{ fd_icon('sparkles',14) }} Icon</span>
|
||||
<span x-show="iconOpen">{{ fd_icon('x',14) }} Hide</span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="coverOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<input type="text" class="sd-input" x-model="coverUrl" placeholder="Cover image URL" style="flex:1;min-width:200px;">
|
||||
<button class="sd-btn-primary" @click="setCoverUrl()" :disabled="coverLoading" x-show="!coverLoading">Set</button>
|
||||
<button class="sd-btn-primary" :disabled="coverLoading" x-show="coverLoading">Saving…</button>
|
||||
<label class="sd-btn-secondary" style="display:flex;align-items:center;gap:6px;">
|
||||
{{ fd_icon('upload',14) }} Upload
|
||||
<input type="file" accept="image/*" @change="uploadCover($event)" style="display:none;">
|
||||
</label>
|
||||
<button class="sd-btn-danger" @click="removeCover()" x-show="coverUrl">Remove</button>
|
||||
</div>
|
||||
<div x-show="coverUrl" style="max-width:320px;border-radius:8px;overflow:hidden;border:1px solid var(--border);">
|
||||
<img :src="coverUrl" style="width:100%;height:auto;display:block;">
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
{{ fd_icon("link",14) }} Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PUBLISH TAB ═══════ -->
|
||||
<div
|
||||
class="sd-body"
|
||||
x-show="shareTab === 'publish'"
|
||||
x-transition
|
||||
>
|
||||
<!-- Before publishing -->
|
||||
<div x-show="!pagePublished" class="sd-publish-hero">
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
{{ fd_icon('globe',14) }} {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
<p class="sd-publish-desc">
|
||||
Make this page visible to anyone on the internet.
|
||||
You can share the link with anyone.
|
||||
</p>
|
||||
<button
|
||||
class="sd-btn-primary sd-btn-full"
|
||||
@click="publishPage()"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<p class="sd-publish-note">
|
||||
Your page will be visible to anyone with the link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After publishing -->
|
||||
<div x-show="pagePublished">
|
||||
<div class="sd-url-bar">
|
||||
<span
|
||||
style="font-size: 13px; color: var(--text-dim)"
|
||||
>{{ workspace_key or 'flowdeck' }}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="sd-url-input"
|
||||
:value="publishedUrl || pageUrl"
|
||||
readonly
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="sd-btn-primary"
|
||||
style="
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
"
|
||||
@click="copyPageLink"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("link",14) }} Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("edit",14) }} Allow editing</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowEdit"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowComments"
|
||||
checked
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<button
|
||||
class="sd-danger-btn"
|
||||
@click="unpublishPage()"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Activity Popover ═══════════ -->
|
||||
<div class="activity-popover"
|
||||
x-show="activityOpen"
|
||||
@click.outside="!_justOpened && (activityOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
||||
<template x-if="pageCreated">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- More menu dropdown -->
|
||||
<div
|
||||
class="more-menu"
|
||||
x-show="moreOpen"
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="toggleBacklinks">
|
||||
{{ fd_icon("link-2",14) }} Linked from
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleVersions">
|
||||
{{ fd_icon("history",14) }} Version history
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleImport">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
|
||||
<template x-if="exportOpen">
|
||||
<div class="more-menu-sub">
|
||||
<div class="more-menu-item" @click="exportPage('markdown')"> Markdown (.md)</div>
|
||||
<div class="more-menu-item" @click="exportPage('html')"> HTML (.html)</div>
|
||||
<div class="more-menu-item" @click="exportPage('pdf')"> PDF (.pdf)</div>
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="contentFormat === 'file' && fileUrl">
|
||||
<div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="downloadFile()">
|
||||
{{ fd_icon("download",14) }} Download
|
||||
</div>
|
||||
<div class="more-menu-item" @click="copyFileContent()">
|
||||
{{ fd_icon("copy",14) }} Copy content
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Move to Dialog ═══════════ -->
|
||||
<div class="move-dialog"
|
||||
x-show="moveOpen"
|
||||
@click.outside="moveOpen = false"
|
||||
x-transition.opacity.scale
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
||||
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
||||
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
||||
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
||||
<div class="move-ws-item"
|
||||
@click="doMove(ws.id)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
||||
<span>{{ fd_icon("folder",14) }}</span>
|
||||
<span x-text="ws.name" style="flex:1;"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
||||
No workspaces available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
|
||||
<img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blocks-container"
|
||||
id="_blocksCt"
|
||||
@click="onCtClick($event)"
|
||||
></div>
|
||||
<div
|
||||
id="_slashMenu"
|
||||
class="slash-menu"
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ @mention autocomplete (v4.9.0) ═══════════ -->
|
||||
<div id="_mentionMenu" class="mention-menu"
|
||||
style="display:none;position:fixed;z-index:1200;min-width:240px;max-height:280px;overflow-y:auto;
|
||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||
|
||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
||||
<button type="button" id="_commentSelBtn"
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
|
||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
|
||||
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||
<div class="comments-drawer-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Comments</span>
|
||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
<div class="comment-thread" style="margin-bottom:18px;"
|
||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
||||
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
||||
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
|
||||
</div>
|
||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
||||
<textarea x-model="commentDraft" rows="2"
|
||||
placeholder="Add a comment... Use @ to mention someone."
|
||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
<div
|
||||
class="sd-toast"
|
||||
x-show="toastVisible"
|
||||
x-transition
|
||||
x-text="toastMsg"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
||||
<div
|
||||
class="get-started-toolbar"
|
||||
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
||||
>
|
||||
<span class="gs-label">Get started with</span>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="askAI()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="meetingNote()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="createDatabase()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="createForm()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="gsMoreOpen = !gsMoreOpen"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.outside="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("bar-chart",14) }} Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("copy",14) }} Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon('list',14) }} List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("image",14) }} Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
<span x-show="!saving&&!dirty&&lastSaved"
|
||||
>Saved <span x-text="lastSaved"></span
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
|
||||
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
|
||||
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
|
||||
@click.self="closeDbTemplatePicker()">
|
||||
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Create a database</h3>
|
||||
<button class="flowdeck-modal-close" @click="closeDbTemplatePicker()">×</button>
|
||||
</div>
|
||||
<div style="padding:12px 20px;overflow-y:auto;">
|
||||
<div x-show="dbTplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Loading templates…</div>
|
||||
<div x-show="!dbTplLoading">
|
||||
<!-- Blank -->
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(null)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;">📋</span>
|
||||
<div><div style="font-weight:600;color:var(--text,#fff);font-size:14px;">Blank database</div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);">Start from an empty database</div></div>
|
||||
</div>
|
||||
<template x-for="t in dbTemplates" :key="t.id">
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(t.name)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;" x-text="t.icon||'📋'"></span>
|
||||
<div style="flex:1;"><div style="font-weight:600;color:var(--text,#fff);font-size:14px;" x-text="t.name"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);" x-text="t.description||''"></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!dbTplLoading && !dbTemplates.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
|
||||
No templates available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
@click.outside="versionsOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('history',14) }} Version history</span>
|
||||
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!versionsLoading && versionsList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
|
||||
</template>
|
||||
<template x-for="v in versionsList" :key="v.id">
|
||||
<div class="version-item"
|
||||
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
|
||||
@click="restoreVersion(v.id)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
|
||||
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
|
||||
<div class="backlinks-popover"
|
||||
x-show="backlinksOpen"
|
||||
@click.outside="backlinksOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('link-2',14) }} Linked from</span>
|
||||
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!backlinksLoading && backlinksList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
|
||||
<div class="import-modal"
|
||||
x-show="importOpen"
|
||||
@click.self="importOpen = false"
|
||||
x-transition.opacity
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
|
||||
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
|
||||
<button class="flowdeck-modal-close" @click="importOpen = false">×</button>
|
||||
</div>
|
||||
<div style="padding:20px;overflow-y:auto;flex:1;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Markdown text import -->
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
|
||||
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import as new page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,505 @@
|
||||
<style>
|
||||
/* ── v5.13.0 Realtime : présence + curseurs live ── */
|
||||
.rt-presence{display:inline-flex;align-items:center;gap:2px;margin-right:4px;}
|
||||
.rt-presence .rt-avatar{width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;border:2px solid var(--bg-secondary,#1f1f1f);cursor:default;user-select:none;}
|
||||
.rt-presence .rt-avatar.rt-me{opacity:.85;box-shadow:0 0 0 1px var(--text-primary,#e6e6e6);}
|
||||
.rt-presence .rt-count{font-size:11px;color:var(--text-tertiary,#999);margin-left:2px;}
|
||||
.rt-cursors{position:fixed;inset:0;pointer-events:none;z-index:1200;}
|
||||
.rt-cursor{position:fixed;transform:translate(-1px,-1px);min-width:2px;width:2px;z-index:1201;border-radius:1px;}
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
ops.push({ type: 'update', block: clone(b) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
out = (blocks || []).map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
} catch (e) { return; }
|
||||
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
|
||||
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
{% macro render_workspace_tree(pages, depth=0) %}
|
||||
{% for page in pages %}
|
||||
{% set safe_name = page.name | replace("'", "\\'") %}
|
||||
@@ -25,7 +26,8 @@
|
||||
{% else %}
|
||||
<span style="width:12px;flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
<span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||
{% if page.is_shared %}<span class="page-shared-badge" title="Partagé">👥</span>{% endif %}
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}👤{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
|
||||
{% block page_title %}Accounts{% endblock %}
|
||||
{% block title_prefix %}Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">👤</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
|
||||
<h1>Account Management</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
|
||||
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
|
||||
<span style="font-size:20px;">🔗</span>
|
||||
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600;">Gitea</div>
|
||||
<div style="font-size:12px; color:var(--text-dim);">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #}
|
||||
<div class="fd-agent-msg fd-agent-msg-{{ role }}">
|
||||
<div class="fd-agent-msg-avatar">
|
||||
{% if role == 'assistant' %}🤖{% else %}👤{% endif %}
|
||||
</div>
|
||||
<div class="fd-agent-msg-body">
|
||||
<div class="fd-agent-msg-meta">{{ role }} · {{ model or '' }}</div>
|
||||
<div class="fd-agent-msg-content">{{ content }}</div>
|
||||
{% if tool_calls %}
|
||||
<ul class="fd-agent-msg-tools">
|
||||
{% for call in (tool_calls | fromjson_json) %}
|
||||
<li>🛠 {{ call.name }} — <code>{{ call.arguments | tojson }}</code></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
+787
-126
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📁{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
|
||||
{% block page_title %}Kanban board new{% endblock %}
|
||||
{% block title_prefix %}Kanban board new{% endblock %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Page title -->
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">📁</span>
|
||||
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
|
||||
<h1>{{ repo }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,31 +41,31 @@
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📊</span> Kanban board
|
||||
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
|
||||
</button>
|
||||
<button class="view-tab" data-view="detailed"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📋</span> Detailed board
|
||||
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
|
||||
</button>
|
||||
<button class="view-tab" data-view="table"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">☰</span> Table view
|
||||
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
|
||||
</button>
|
||||
<button class="view-tab" data-view="status"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">📈</span> Status overview
|
||||
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
|
||||
</button>
|
||||
<button class="view-tab" data-view="teamload"
|
||||
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
|
||||
hx-target="#view-content" hx-swap="innerHTML"
|
||||
@click="setActiveTab($el)">
|
||||
<span class="tab-icon">👥</span> Team Load
|
||||
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<span class="sort-direction" @click="toggleDir(i)">
|
||||
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
|
||||
</span>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
|
||||
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
|
||||
@@ -119,8 +119,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="toolbar-btn" title="Properties">⚡</button>
|
||||
<button class="toolbar-btn" title="Search">🔍</button>
|
||||
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
|
||||
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
|
||||
<button class="toolbar-btn">⋯</button>
|
||||
<button class="btn-new" @click="showNewIssue()">New ▾</button>
|
||||
</div>
|
||||
@@ -152,9 +152,9 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
|
||||
<div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span> {{ card.due_date }}
|
||||
<span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
</div>
|
||||
<!-- Assignee -->
|
||||
<div class="filter-pill" style="cursor:pointer;">
|
||||
<span>👤</span>
|
||||
<span>{{ fd_icon("user",14) }}</span>
|
||||
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
|
||||
<span style="font-size:10px;">▾</span>
|
||||
</div>
|
||||
<!-- Deadline -->
|
||||
<div class="filter-pill" style="cursor:pointer;">
|
||||
<span>📅</span>
|
||||
<span>{{ fd_icon("calendar",14) }}</span>
|
||||
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
|
||||
</div>
|
||||
<!-- Labels -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}🏠{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
|
||||
{% block page_title %}Home{% endblock %}
|
||||
{% block title_prefix %}Home{% endblock %}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
|
||||
onmouseover="this.style.background='var(--bg-hover)'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<span style="font-size:18px;">📁</span>
|
||||
<span style="font-size:18px;">{{ fd_icon("folder",20) }}</span>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{{ repo.name }}
|
||||
@@ -57,7 +57,7 @@
|
||||
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
|
||||
{% endif %}
|
||||
{% if repo.private %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">🔒</span>
|
||||
<span style="font-size:12px; color: var(--text-dim);">{{ fd_icon("lock",14) }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size:12px; color: var(--text-dim);">→</span>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
{% if not repos %}
|
||||
<div style="text-align:center; padding:48px; color:var(--text-dim);">
|
||||
<div style="font-size:48px; margin-bottom:16px;">📭</div>
|
||||
<div style="margin-bottom:16px;">{{ fd_icon("inbox",48) }}</div>
|
||||
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
|
||||
<p style="margin-top:8px;">
|
||||
{% if search %}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
{% endif %}
|
||||
{% if card.due_date %}
|
||||
<div class="card-date">
|
||||
<span>📅</span>
|
||||
<span>{{ fd_icon("calendar",14) }}</span>
|
||||
<span style="font-size:12px;">{{ card.due_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}🔗{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "🔗" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -164,7 +164,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!r.ok) {
|
||||
// If API fails (e.g. no Gitea token), set a message
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
||||
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
||||
return;
|
||||
}
|
||||
var d = await r.json();
|
||||
@@ -180,7 +180,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var html = '';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var icon = item.type === 'folder' ? '📁' : '📄';
|
||||
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">' + icon + '</span>';
|
||||
html += '<span class="page-name">' + item.name + '</span>';
|
||||
@@ -189,7 +189,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// Add Private Pages link
|
||||
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
||||
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span></li>';
|
||||
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
||||
container.innerHTML = html;
|
||||
// Re-attach event listeners
|
||||
this.setupSidebarClicks();
|
||||
@@ -270,7 +270,7 @@ document.addEventListener('alpine:init', () => {
|
||||
ul.className = 'sidebar-items';
|
||||
ul.style.paddingLeft = '20px';
|
||||
children.forEach(function(child) {
|
||||
var icon = child.type === 'folder' ? '📁' : '📄';
|
||||
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var li = document.createElement('li');
|
||||
li.className = 'sidebar-item';
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
@@ -290,7 +290,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'sidebar-icon';
|
||||
iconSpan.textContent = icon;
|
||||
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
||||
li.appendChild(iconSpan);
|
||||
// Name
|
||||
var nameSpan = document.createElement('span');
|
||||
@@ -669,8 +669,8 @@ document.addEventListener('alpine:init', () => {
|
||||
<strong x-text="filePath || ''"></strong>
|
||||
<span x-text="formatSize(fileSize)"></span>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="openEditor()">✏️ Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
|
||||
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
@@ -710,7 +710,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<option :value="msg">
|
||||
</template>
|
||||
</datalist>
|
||||
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
|
||||
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -734,9 +734,9 @@ document.addEventListener('alpine:init', () => {
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="treeItems.length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>📁 Empty</h2>
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
<button class="btn btn-primary" @click="showNewFile=!showNewFile">📄+ New file</button>
|
||||
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -746,7 +746,7 @@ document.addEventListener('alpine:init', () => {
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span>
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -759,8 +759,8 @@ document.addEventListener('alpine:init', () => {
|
||||
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
||||
@click.outside="gwCtx.visible = false"
|
||||
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
|
||||
<div class="ctx-item" @click="gwRename()">✏️ Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div>
|
||||
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -769,7 +769,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
|
||||
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
|
||||
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||
<button class="btn btn-primary" @click="createNewFile()">💾 Create file</button>
|
||||
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -778,7 +778,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<template x-if="showPrivate && !editingPrivate">
|
||||
<div class="gw-content">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>🔒 Private Pages</h3>
|
||||
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
|
||||
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
|
||||
</div>
|
||||
<template x-if="privatePages.length === 0">
|
||||
@@ -793,7 +793,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -805,7 +805,7 @@ document.addEventListener('alpine:init', () => {
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">💾 Save</button>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<title>FlowDeck — All-in-one workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
@@ -142,7 +143,7 @@
|
||||
|
||||
<nav class="landing-nav">
|
||||
<a href="/" class="landing-logo">
|
||||
<span>📚</span> FlowDeck
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
@@ -158,39 +159,39 @@
|
||||
All on your own server.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/auth/register" class="btn btn-primary">🚀 Get started — it's free</a>
|
||||
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📝</div>
|
||||
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||
<h3>Block Editor</h3>
|
||||
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<div class="feature-icon">{{ fd_icon("bar-chart",24) }}</div>
|
||||
<h3>Kanban & Tables</h3>
|
||||
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🦎</div>
|
||||
<div class="feature-icon">{{ fd_icon("link",24) }}</div>
|
||||
<h3>Gitea & GitHub</h3>
|
||||
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<div class="feature-icon">{{ fd_icon("globe",24) }}</div>
|
||||
<h3>Publish to Web</h3>
|
||||
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<div class="feature-icon">{{ fd_icon("lock",24) }}</div>
|
||||
<h3>Self-Hosted</h3>
|
||||
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<div class="feature-icon">{{ fd_icon("zap",24) }}</div>
|
||||
<h3>Fast & Light</h3>
|
||||
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
|
||||
</div>
|
||||
|
||||
+311
-108
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}📚{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("book",18) }}{% endblock %}
|
||||
{% block page_title %}Library{% endblock %}
|
||||
{% block title_prefix %}Library{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "📚" %}
|
||||
{% set page_icon = "book" %}
|
||||
{% set page_title = "Library" %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
@@ -88,6 +88,19 @@
|
||||
.row-title:hover{text-decoration:underline;}
|
||||
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
|
||||
/* ── Tag chips (représentation + filtre) ── */
|
||||
.row-tags{display:flex;align-items:center;gap:3px;flex-shrink:0;overflow:hidden;max-width:45%;}
|
||||
.tag-chip{display:inline-flex;align-items:center;gap:3px;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;color:#fff;white-space:nowrap;border:none;cursor:pointer;font-family:inherit;}
|
||||
.tag-chip.active{outline:2px solid var(--text-primary);outline-offset:1px;}
|
||||
.lib-tag-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 16px 2px;}
|
||||
.lib-tag-filter .tf-label{font-size:11px;color:var(--text-dim);margin-right:2px;}
|
||||
|
||||
/* ── Shared direction sub-filter pils ── */
|
||||
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
|
||||
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
|
||||
.lib-dir-pill:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.lib-dir-pill.active{background:var(--accent);border-color:var(--accent);color:#fff;}
|
||||
|
||||
/* ── Hover-only elements (hidden until row hover, or when checked/dragging) ── */
|
||||
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
|
||||
.lib-row:hover .hover-only,.lib-row.selected .hover-only{opacity:1;}
|
||||
@@ -186,7 +199,7 @@
|
||||
<div class="lib-header">
|
||||
<h1>Library</h1>
|
||||
<div class="lib-header-actions">
|
||||
<button class="lib-btn-primary" onclick="window.FlowDeck.createPage()">
|
||||
<button class="lib-btn-primary" x-show="!noWorkspace" onclick="window.FlowDeck.createPage()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New page
|
||||
</button>
|
||||
@@ -247,7 +260,7 @@
|
||||
</button>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||
<div class="dd-label">Show columns</div>
|
||||
@@ -258,6 +271,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared direction sub-filter (Par moi / Avec moi) -->
|
||||
<div class="lib-share-directory" x-show="tab === 'shared'">
|
||||
<button class="lib-dir-pill" :class="{ active: shareDir === 'all' }" @click="setShareDir('all')">Tous</button>
|
||||
<button class="lib-dir-pill" :class="{ active: shareDir === 'made' }" @click="setShareDir('made')">Par moi</button>
|
||||
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter bar -->
|
||||
<div class="lib-tag-filter" x-show="workspaceTags && workspaceTags.length > 0" x-cloak>
|
||||
<span class="tf-label">Tags:</span>
|
||||
<template x-for="t in workspaceTags" :key="'ltf'+t.id">
|
||||
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
|
||||
:style="{ background: t.color }"
|
||||
@click="toggleTagFilter(t.name)">
|
||||
<span x-text="t.name"></span>
|
||||
<span style="font-size:10px;opacity:.7" x-text="'('+(t.count||0)+')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<button x-show="tagFilter" class="tag-chip" style="background:var(--bg-tertiary);color:var(--text-secondary);" @click="tagFilter='', _recomputeFlatItems()">clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar (toggleable) -->
|
||||
<div class="lib-search-bar" x-show="searchOpen" x-transition>
|
||||
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
|
||||
@@ -266,6 +300,10 @@
|
||||
<!-- ── Multi-select bar ── -->
|
||||
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
|
||||
<span class="sel-count" x-text="selectedCount + ' selected'"></span>
|
||||
<button @click="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<button @click="clearSelection()" title="Clear selection">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -302,14 +340,21 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Loading ── -->
|
||||
<div class="lib-loading" id="lib-loading">
|
||||
<p>Loading…</p>
|
||||
<div class="lib-loading" id="lib-loading" x-show="loading">
|
||||
<p x-show="!noWorkspace">Loading…</p>
|
||||
<div x-show="noWorkspace" style="padding:40px;text-align:center;">
|
||||
<div style="margin-bottom:16px;">{{ fd_icon("folder",32) }}</div>
|
||||
<h3 style="font-size:18px;margin:0 0 8px;">No workspace open</h3>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin:0 0 20px;">Open a workspace to browse and manage your library items.</p>
|
||||
<a href="/workspaces" class="btn btn-primary" style="display:inline-block;text-decoration:none;padding:8px 20px;">Go to Workspaces</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
|
||||
<div class="lib-table-header">
|
||||
<div class="th lib-col-name">
|
||||
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Page name
|
||||
</div>
|
||||
@@ -325,7 +370,7 @@
|
||||
</div>
|
||||
|
||||
<!-- "+ Add new" (root level) -->
|
||||
<div class="add-new-row" x-show="!loading && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
|
||||
<div class="add-new-row" x-show="!loading && !noWorkspace && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
|
||||
onclick="window.FlowDeck.createPage()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Add new
|
||||
@@ -333,53 +378,13 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div class="lib-empty" id="lib-empty">
|
||||
<div class="empty-icon" x-text="emptyIcon"></div>
|
||||
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row context menu ── -->
|
||||
<div class="more-menu" x-show="ctxMenuItem" @click.outside="ctxMenuItem=null" x-transition
|
||||
:style="'top:' + ctxMenuY + 'px;left:' + ctxMenuX + 'px;'">
|
||||
<template x-if="ctxMenuItem">
|
||||
<div>
|
||||
<div class="menu-item" @click="openItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Open
|
||||
</div>
|
||||
<div class="menu-item" @click="openPeek(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
Open in side peek
|
||||
</div>
|
||||
<div class="menu-item" @click="startRename(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
|
||||
Rename
|
||||
</div>
|
||||
<div class="menu-item" @click="toggleFavoriteItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
<span x-text="ctxMenuItem.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
<div class="menu-item" @click="duplicateItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
Duplicate
|
||||
</div>
|
||||
<div class="menu-item" @click="copyLink(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy link
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker([ctxMenuItem.id]); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="deleteItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- ── Move-to modal ── -->
|
||||
<div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition>
|
||||
@@ -389,10 +394,10 @@
|
||||
<input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()">
|
||||
</div>
|
||||
<div class="move-modal-list">
|
||||
<div class="move-modal-item" @click="confirmMove(0)">📄 <span>Root (no parent)</span></div>
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-text="it.icon || '📄'"></span> <span x-text="it.title"></span>
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -401,6 +406,7 @@
|
||||
|
||||
<!-- ── Side Peek Panel ── -->
|
||||
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
||||
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
|
||||
<template x-if="peekItem">
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div class="peek-header">
|
||||
@@ -408,7 +414,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-text="peekItem.icon || '📄'"></span>
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
@@ -439,13 +445,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peek-body">
|
||||
<div x-show="peekLoading" class="peek-loading">Loading…</div>
|
||||
<div x-show="!peekLoading && peekContent" x-html="peekContent"></div>
|
||||
<div x-show="!peekLoading && !peekContent" class="peek-empty">
|
||||
<div style="font-size:48px;margin-bottom:8px;">📄</div>
|
||||
<div style="font-size:14px;color:var(--text-secondary);">No preview available</div>
|
||||
</div>
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
||||
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -455,7 +456,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
@@ -483,24 +484,27 @@ function libraryPage() {
|
||||
sortOpen: false,
|
||||
viewOpen: false,
|
||||
sourceFilter: 'all',
|
||||
shareDir: 'all',
|
||||
showAuthor: false,
|
||||
showSource: false,
|
||||
|
||||
// Tags
|
||||
workspaceTags: [],
|
||||
tagFilter: '',
|
||||
|
||||
// Sort
|
||||
sortBy: '',
|
||||
|
||||
// More menu
|
||||
moreOpen: false,
|
||||
|
||||
// Row context menu
|
||||
ctxMenuItem: null,
|
||||
ctxMenuX: 0,
|
||||
ctxMenuY: 0,
|
||||
|
||||
// Move-to modal
|
||||
moveModalOpen: false,
|
||||
moveIds: [],
|
||||
moveSearchQuery: '',
|
||||
|
||||
// No workspace guard
|
||||
noWorkspace: false,
|
||||
moveCandidates: [],
|
||||
_moveAllCandidates: [],
|
||||
|
||||
@@ -574,19 +578,24 @@ function libraryPage() {
|
||||
} else {
|
||||
html += '<span style="width:18px;flex-shrink:0;"></span>';
|
||||
}
|
||||
// Icon — folders get a folder icon
|
||||
// Icon — monochrome, comme local-workspace (emoji custom si défini)
|
||||
var isFolder = item.is_folder || item.has_children;
|
||||
if (isFolder) {
|
||||
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
|
||||
} else {
|
||||
html += '<span class="row-icon">' + (item.icon || '\ud83d\udcc4') + '</span>';
|
||||
}
|
||||
html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
|
||||
// Title or rename input
|
||||
if (isRenaming) {
|
||||
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
|
||||
} else {
|
||||
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
|
||||
}
|
||||
// Tags
|
||||
if (item.tags && item.tags.length) {
|
||||
html += '<span class="row-tags">';
|
||||
for (var ti = 0; ti < item.tags.length; ti++) {
|
||||
var tg = item.tags[ti];
|
||||
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
}
|
||||
// OPEN button (side peek) — hidden for folders
|
||||
if (!isFolder) {
|
||||
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
|
||||
@@ -608,6 +617,16 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji (image URL), icon name, or unicode emoji
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
|
||||
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
|
||||
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
|
||||
var n = (item.title || '').toLowerCase();
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
|
||||
return getSvgIcon('file', 14);
|
||||
},
|
||||
|
||||
_recomputeFlatItems() {
|
||||
var result = [];
|
||||
@@ -618,6 +637,12 @@ function libraryPage() {
|
||||
return (r.title || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
if (this.tagFilter) {
|
||||
var tf = this.tagFilter;
|
||||
result = result.filter(function(r) {
|
||||
return (r.tags || []).some(function(t) { return t.name === tf; });
|
||||
});
|
||||
}
|
||||
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
|
||||
this.flatItems.splice(0, this.flatItems.length, ...result);
|
||||
this.selectedCount = Object.keys(this.selected).length;
|
||||
@@ -680,9 +705,60 @@ function libraryPage() {
|
||||
evt.preventDefault();
|
||||
var item = this._findItem(id);
|
||||
if (!item) return;
|
||||
this.ctxMenuItem = item;
|
||||
this.ctxMenuX = Math.min(evt.clientX, window.innerWidth - 220);
|
||||
this.ctxMenuY = Math.min(evt.clientY, window.innerHeight - 320);
|
||||
if (!(window.Alpine && Alpine.store('fdCtx'))) return;
|
||||
/* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library
|
||||
complète l'union. Open folder/tags non prévus côté library :
|
||||
fdCtx les cache automatiquement via handlers absents. */
|
||||
var self = this;
|
||||
var handlers = {
|
||||
open: function (n) { self.openItem(n); },
|
||||
openTab: function (n) { self.openInNewTab(n); },
|
||||
peek: function (n) { self.openPeek(n); },
|
||||
rename: function (n) { self.startRename(n); },
|
||||
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self.openMovePicker([n.id]); },
|
||||
fav: function (n) { self.toggleFavoriteItem(n); },
|
||||
delete: function (n) { self.deleteItem(n); },
|
||||
tagExisting: function (t) { self.ctxAddExistingTag(t); },
|
||||
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
|
||||
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
|
||||
};
|
||||
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
|
||||
var store = Alpine.store('fdCtx');
|
||||
store.setAvail(self._ctxAvailTags(item));
|
||||
store.openMenu(evt, item, 'library', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Drag & drop (move / reparent) ──
|
||||
@@ -773,13 +849,13 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
// ── Empty states ──
|
||||
emptyIcon: '📄',
|
||||
emptyIcon: 'file',
|
||||
emptyTitle: 'No items',
|
||||
emptyText: '',
|
||||
|
||||
_recomputeEmpty() {
|
||||
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁',repository:'📦'};
|
||||
this.emptyIcon = icons[this.tab] || '📄';
|
||||
var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
|
||||
this.emptyIcon = icons[this.tab] || 'file';
|
||||
var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages',
|
||||
published:'No published pages',private:'No private pages',workspace:'No workspace pages',
|
||||
repository:'Empty repository'};
|
||||
@@ -804,6 +880,12 @@ function libraryPage() {
|
||||
this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1';
|
||||
this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || '';
|
||||
this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || '';
|
||||
// Detect no workspace: no local wsId AND no Gitea active
|
||||
if (!this.workspaceId && !this.isGiteaActive) {
|
||||
this.noWorkspace = true;
|
||||
this.loading = false; // don't show loading spinner
|
||||
return;
|
||||
}
|
||||
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var t = params.get('tab');
|
||||
@@ -811,6 +893,7 @@ function libraryPage() {
|
||||
this.tab = t;
|
||||
}
|
||||
this._recomputeEmpty();
|
||||
this.loadWorkspaceTags();
|
||||
console.log('[Library] loading tab: ' + this.tab);
|
||||
this.loadTab();
|
||||
},
|
||||
@@ -819,6 +902,8 @@ function libraryPage() {
|
||||
if (this.tab === t) return;
|
||||
if (t === 'repository' && !this.isGiteaActive) return;
|
||||
this.tab = t;
|
||||
this.shareDir = 'all';
|
||||
this.tagFilter = '';
|
||||
this.items = [];
|
||||
this.selected = {};
|
||||
this.selectedCount = 0;
|
||||
@@ -857,6 +942,9 @@ function libraryPage() {
|
||||
} else if (this.tab !== 'repository' && this.tab !== 'workspace' && this.sourceFilter && this.sourceFilter !== 'all') {
|
||||
url += sep + 'source_type=' + encodeURIComponent(this.sourceFilter); sep = '&';
|
||||
}
|
||||
if (this.tab === 'shared' && this.shareDir && this.shareDir !== 'all') {
|
||||
url += sep + 'dir=' + encodeURIComponent(this.shareDir); sep = '&';
|
||||
}
|
||||
var r = await fetch(url);
|
||||
var d = await r.json();
|
||||
// Backend already computes has_children correctly for each item (children
|
||||
@@ -881,6 +969,12 @@ function libraryPage() {
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
setShareDir(v) {
|
||||
if (this.shareDir === v) return;
|
||||
this.shareDir = v;
|
||||
this.loadTab();
|
||||
},
|
||||
|
||||
setSort(field) {
|
||||
this.sortBy = field;
|
||||
this.sortOpen = false;
|
||||
@@ -1031,6 +1125,105 @@ function libraryPage() {
|
||||
if (window.showToast) window.showToast('Link copied', 'success');
|
||||
},
|
||||
|
||||
openInNewTab(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.open('/pages/' + item.id, '_blank');
|
||||
},
|
||||
|
||||
removeItemFromRecents(item) {
|
||||
if (!item) return;
|
||||
var id = item.id;
|
||||
this.items = this.items.filter(function(it) { return it.id !== id; });
|
||||
this._recomputeFlatItems();
|
||||
if (window.showToast) window.showToast('Removed from Recents', 'success');
|
||||
},
|
||||
|
||||
async setItemIcon(item, icon) {
|
||||
if (!item || !item.id) return;
|
||||
icon = (icon || '').trim();
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this._getCsrf()},
|
||||
body: JSON.stringify({icon: icon})
|
||||
});
|
||||
if (!r.ok) throw new Error('icon update failed');
|
||||
item.page_icon = icon;
|
||||
this._renderTable();
|
||||
if (window.showToast) window.showToast('Icon updated', 'success');
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to update icon', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tags ──
|
||||
async loadWorkspaceTags() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tags/all');
|
||||
var d = await r.json();
|
||||
this.workspaceTags = d.tags || [];
|
||||
} catch(e) { this.workspaceTags = []; }
|
||||
},
|
||||
|
||||
toggleTagFilter(tagName) {
|
||||
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
||||
this._recomputeFlatItems();
|
||||
},
|
||||
|
||||
_ctxAvailTags(item) {
|
||||
return (this.workspaceTags || []).filter(function(t) {
|
||||
return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||||
});
|
||||
},
|
||||
|
||||
_ctxRefreshAvail() {
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
|
||||
},
|
||||
|
||||
ctxAddExistingTag(tag) {
|
||||
this.ctxAddNewTag(tag.name, tag.color);
|
||||
},
|
||||
|
||||
ctxRemoveTag(tagId) {
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
var self = this;
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return;
|
||||
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
|
||||
self.loadWorkspaceTags();
|
||||
self._ctxRefreshAvail();
|
||||
self._recomputeFlatItems();
|
||||
});
|
||||
},
|
||||
|
||||
async ctxAddNewTag(tagName, color) {
|
||||
tagName = (tagName || '').trim();
|
||||
if (!tagName) return;
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
var cur = item.tags || [];
|
||||
if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]);
|
||||
await this.loadWorkspaceTags();
|
||||
this._ctxRefreshAvail();
|
||||
this._recomputeFlatItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async toggleFavoriteItem(item) {
|
||||
if (!item) return;
|
||||
var csrf = this._getCsrf();
|
||||
@@ -1110,7 +1303,8 @@ function libraryPage() {
|
||||
// ── Item actions ──
|
||||
openItem(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.location.href = '/pages/' + item.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
|
||||
else window.location.href = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
startRename(item) {
|
||||
@@ -1168,7 +1362,8 @@ function libraryPage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/' + d.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
@@ -1180,40 +1375,11 @@ function libraryPage() {
|
||||
// ── Side peek ──
|
||||
async openPeek(item) {
|
||||
this.peekItem = item;
|
||||
this.peekContent = '';
|
||||
this.peekLoading = true;
|
||||
this.peekFavorited = item.favorited || false;
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + item.id);
|
||||
var d = await r.json();
|
||||
var content = d.content || '';
|
||||
// Simple preview
|
||||
if (d.content_format === 'blocks') {
|
||||
try {
|
||||
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
var preview = '';
|
||||
var lines = Array.isArray(blocks) ? blocks.slice(0, 30) : ((blocks && blocks.blocks) ? blocks.blocks.slice(0, 30) : []);
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var b = lines[i];
|
||||
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
|
||||
if (txt && txt.trim()) preview += '<div style="margin-bottom:6px;font-size:14px;line-height:1.5;color:var(--text-primary);">' + this._escHtml(String(txt)) + '</div>';
|
||||
}
|
||||
this.peekContent = preview || '<div style="color:var(--text-dim);">(empty page)</div>';
|
||||
} catch(e) {
|
||||
this.peekContent = '<pre style="font-size:13px;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
|
||||
}
|
||||
} else {
|
||||
this.peekContent = content ? '<div style="font-size:14px;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</div>' : '';
|
||||
}
|
||||
} catch(e) {
|
||||
this.peekContent = '';
|
||||
}
|
||||
this.peekLoading = false;
|
||||
},
|
||||
|
||||
closePeek() {
|
||||
this.peekItem = null;
|
||||
this.peekContent = '';
|
||||
},
|
||||
|
||||
async toggleFavoritePeek() {
|
||||
@@ -1281,5 +1447,42 @@ function libraryPage() {
|
||||
}
|
||||
};
|
||||
}
|
||||
// Init peek resize handle
|
||||
(function() {
|
||||
var panel = document.querySelector('.peek-overlay');
|
||||
var handle = document.getElementById('lib-peek-resize-handle');
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var deltaX = startX - e.clientX;
|
||||
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', function(e) {
|
||||
if (!dragging) return;
|
||||
document.body.style.userSelect = '';
|
||||
var moved = Math.abs(e.clientX - startX);
|
||||
if (moved < 3) {
|
||||
var el = document.querySelector('.peek-overlay');
|
||||
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {}
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch(ex) {}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+894
-449
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
|
||||
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
||||
<div class="notes-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
|
||||
<button type="submit" class="btn btn-primary">{{ fd_icon("save",14) }} Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,935 +1,14 @@
|
||||
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}{% endblock %} {% block
|
||||
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}{% endblock %} {% block
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "📄" if page.content_format != 'file' else "📎" %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">🔒 Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">🔗</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-text="(window.E && window.E.favorited) ? \'⭐\' : \'☆\'"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
<div class="page-editor-wrapper" x-data="editorState()">
|
||||
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
|
||||
|
||||
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
||||
<div
|
||||
class="share-dialog"
|
||||
x-show="shareOpen"
|
||||
@click.outside="!_justOpened && (shareOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
>
|
||||
<!-- Header with tabs -->
|
||||
<div class="sd-header">
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'share' }"
|
||||
@click="shareTab = 'share'"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
class="sd-tab"
|
||||
:class="{ active: shareTab === 'publish' }"
|
||||
@click="shareTab = 'publish'"
|
||||
>
|
||||
Publish
|
||||
<span class="sd-badge" x-show="pagePublished"></span>
|
||||
</button>
|
||||
<button class="sd-close" @click="shareOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="email"
|
||||
class="sd-input"
|
||||
placeholder="Add people, groups or emails..."
|
||||
x-model="inviteEmail"
|
||||
@keydown.enter="shareInvite()"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
<div class="sd-context-card" x-show="false">
|
||||
Share settings on this page are unlinked from parent
|
||||
page
|
||||
</div>
|
||||
|
||||
<!-- Participants list -->
|
||||
<div class="sd-participants" x-show="accessList.length">
|
||||
<template x-for="a in accessList" :key="a.email">
|
||||
{% raw %}
|
||||
<div class="sd-participant">
|
||||
<div class="sd-participant-info">
|
||||
<div
|
||||
class="sd-avatar"
|
||||
x-text="a.email[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.email"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="sd-perm-btn"
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
<div
|
||||
class="sd-perm-menu"
|
||||
x-show="a.permOpen"
|
||||
@click.outside="a.permOpen = false"
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'editor' }"
|
||||
@click="a.permission='editor'; a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Edit, suggest and comment</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'commenter' }"
|
||||
@click="a.permission='commenter'; a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Suggest and comment only</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'viewer' }"
|
||||
@click="a.permission='viewer'; a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Read only</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeAccess(a.email); a.permOpen=false"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- General Access -->
|
||||
<div class="sd-section">
|
||||
<div class="sd-section-title">General access</div>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
class="sd-access-btn"
|
||||
@click="accessMenuOpen = !accessMenuOpen"
|
||||
>
|
||||
<span
|
||||
x-text="generalAccess === 'invited' ? '🔒 Only people invited' : '🌐 Anyone with the link'"
|
||||
></span>
|
||||
<span class="chevron-down">▾</span>
|
||||
</button>
|
||||
<div
|
||||
class="sd-access-menu"
|
||||
x-show="accessMenuOpen"
|
||||
@click.outside="accessMenuOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
>
|
||||
<span>🔒 Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
>People need to be invited</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
>
|
||||
<span>🌐 Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
>Can view the page</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Public permission when link is open -->
|
||||
<div
|
||||
x-show="generalAccess === 'anyone'"
|
||||
style="
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
"
|
||||
>Anyone with the link</span
|
||||
>
|
||||
<select class="sd-select-sm" x-model="publicPerm">
|
||||
<option value="viewer">Can view</option>
|
||||
<option value="commenter">Can comment</option>
|
||||
<option value="editor">Can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
>? Learn about sharing</a
|
||||
>
|
||||
<button class="sd-footer-action" @click="copyPageLink">
|
||||
🔗 Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PUBLISH TAB ═══════ -->
|
||||
<div
|
||||
class="sd-body"
|
||||
x-show="shareTab === 'publish'"
|
||||
x-transition
|
||||
>
|
||||
<!-- Before publishing -->
|
||||
<div x-show="!pagePublished" class="sd-publish-hero">
|
||||
<div class="sd-publish-preview">
|
||||
<div class="sd-preview-bar"></div>
|
||||
<div class="sd-preview-title">
|
||||
🌐 {{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sd-publish-heading">Publish to web</h3>
|
||||
<p class="sd-publish-desc">
|
||||
Make this page visible to anyone on the internet.
|
||||
You can share the link with anyone.
|
||||
</p>
|
||||
<button
|
||||
class="sd-btn-primary sd-btn-full"
|
||||
@click="publishPage()"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<p class="sd-publish-note">
|
||||
Your page will be visible to anyone with the link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- After publishing -->
|
||||
<div x-show="pagePublished">
|
||||
<div class="sd-url-bar">
|
||||
<span
|
||||
style="font-size: 13px; color: var(--text-dim)"
|
||||
>{{ workspace_key or 'flowdeck' }}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="sd-url-input"
|
||||
:value="publishedUrl || pageUrl"
|
||||
readonly
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="sd-btn-primary"
|
||||
style="
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
"
|
||||
@click="copyPageLink"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<!-- Settings list -->
|
||||
<div class="sd-settings">
|
||||
<div class="sd-setting-row">
|
||||
<span>🔗 Link expires</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Never ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>🔍 Search engine indexing</span>
|
||||
<span style="color: var(--text-dim)"
|
||||
>Off ▾</span
|
||||
>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>📄 Allow duplicate as template</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>✏️ Allow editing</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowEdit"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sd-setting-row">
|
||||
<span>💬 Allow comments</span>
|
||||
<label class="toggle-switch sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="pubAllowComments"
|
||||
checked
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sd-sep"></div>
|
||||
|
||||
<button
|
||||
class="sd-danger-btn"
|
||||
@click="unpublishPage()"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Activity Popover ═══════════ -->
|
||||
<div class="activity-popover"
|
||||
x-show="activityOpen"
|
||||
@click.outside="!_justOpened && (activityOpen = false)"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
|
||||
<template x-if="pageCreated">
|
||||
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- More menu dropdown -->
|
||||
<div
|
||||
class="more-menu"
|
||||
x-show="moreOpen"
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
📋 Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
🗑️ Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Move to Dialog ═══════════ -->
|
||||
<div class="move-dialog"
|
||||
x-show="moveOpen"
|
||||
@click.outside="moveOpen = false"
|
||||
x-transition.opacity.scale
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
|
||||
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
|
||||
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
|
||||
<template x-for="ws in moveWorkspaces" :key="ws.id">
|
||||
<div class="move-ws-item"
|
||||
@click="doMove(ws.id)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
|
||||
<span>📁</span>
|
||||
<span x-text="ws.name" style="flex:1;"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
|
||||
No workspaces available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji">{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}</span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blocks-container"
|
||||
id="_blocksCt"
|
||||
@click="onCtClick($event)"
|
||||
></div>
|
||||
<div
|
||||
id="_slashMenu"
|
||||
class="slash-menu"
|
||||
style="display: none; position: fixed; z-index: 999"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">🔗</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
<div
|
||||
class="sd-toast"
|
||||
x-show="toastVisible"
|
||||
x-transition
|
||||
x-text="toastMsg"
|
||||
></div>
|
||||
|
||||
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
||||
<div
|
||||
class="get-started-toolbar"
|
||||
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
||||
>
|
||||
<span class="gs-label">Get started with</span>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
|
||||
>
|
||||
<span class="gs-icon">✨</span> Ask AI
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
||||
>
|
||||
<span class="gs-icon">📝</span> AI meeting note
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
|
||||
>
|
||||
<span class="gs-icon">🗄️</span> Database
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
|
||||
>
|
||||
<span class="gs-icon">📋</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
|
||||
>
|
||||
<span class="gs-icon">📑</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.outside="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📊 Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📋 Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📝 List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📅 Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🗓️ Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
🖼️ Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
📥 Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
<span x-show="!saving&&!dirty&&lastSaved"
|
||||
>Saved <span x-text="lastSaved"></span
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% include "_page_editor_content.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="application/json" id="page-data">
|
||||
{{ page_data | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
/* eslint-disable */
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
|
||||
{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
|
||||
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
|
||||
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
|
||||
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
|
||||
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
|
||||
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
|
||||
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
|
||||
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
|
||||
e.appendChild(hdr);e.appendChild(list);
|
||||
SM._e=e;SM._in=inp;SM._li=list;
|
||||
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
||||
},
|
||||
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
||||
const r=el.getBoundingClientRect();SM._e.style.display='block';
|
||||
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
||||
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
|
||||
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
|
||||
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
||||
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
|
||||
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
|
||||
items.forEach(c=>{SM._it.push(c);
|
||||
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
|
||||
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
|
||||
SM._li.innerHTML=h;},
|
||||
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
||||
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
||||
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
||||
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
|
||||
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
|
||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||
get isOpen(){return SM._o;}};
|
||||
|
||||
function renderBlock(b,idx){
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;"></iframe></div></div>`;
|
||||
if(b.embed_type==='download'){
|
||||
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
|
||||
return `<div class="block-wrapper"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">📎</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...';
|
||||
let inner='';
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
|
||||
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
|
||||
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
||||
return `<div class="block-wrapper">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
|
||||
<${tag}${style} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.E=null;
|
||||
function editorState(){
|
||||
return {
|
||||
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
||||
updatedAt:'{{ page.get("updated_at", "") }}',
|
||||
fmt:{open:false,top:0,left:0,idx:-1},
|
||||
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
||||
moveOpen:false,moveWorkspaces:[],moveLoading:false,
|
||||
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
|
||||
shareTab:'share',favorited:false,
|
||||
pagePublished:{{ page_published | tojson }},
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'editor',accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
get timeAgo() {
|
||||
if (!this.updatedAt) return 'just now';
|
||||
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
formatDate(d) {
|
||||
if (!d) return '';
|
||||
var dt = new Date(d + 'Z');
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
|
||||
' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
},
|
||||
init(){
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';const raw=data.content||'';
|
||||
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
window.E=this;SM.init();this.render();
|
||||
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.addEventListener('keydown',e=>this.onKd(e));
|
||||
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
|
||||
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
|
||||
},100);
|
||||
},
|
||||
fileData:null,
|
||||
async loadFileContent(data){
|
||||
const url=data.file_url||'';const mime=data.file_mime||'';const name=data.file_name||'';const self=this;
|
||||
if(!url){this.blocks=[this.mkB('paragraph','File not available')];this.render();return;}
|
||||
const ext=name.toLowerCase().split('.').pop();
|
||||
if(mime.startsWith('image/')||['png','jpg','jpeg','gif','webp','svg','bmp','ico'].includes(ext)){
|
||||
this.blocks=[this.mkB('image','',{src:url,alt:name})];this.render();return;
|
||||
}
|
||||
if(mime==='application/pdf'||ext==='pdf'){
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'pdf'})];this.render();return;
|
||||
}
|
||||
const codeExts=['py','js','ts','jsx','tsx','html','htm','css','json','xml','yaml','yml','md','markdown','sql','sh','bash','ps1','bat','cmd','rb','go','rs','java','c','cpp','h','hpp','php','swift','kt','scala','r','toml','ini','cfg','conf','env','txt','log'];
|
||||
const langMap={'py':'python','js':'javascript','ts':'typescript','jsx':'javascript','tsx':'typescript','html':'html','htm':'html','css':'css','json':'json','xml':'xml','yaml':'yaml','yml':'yaml','md':'markdown','markdown':'markdown','sql':'sql','sh':'bash','bash':'bash','ps1':'powershell','bat':'plaintext','cmd':'plaintext','rb':'ruby','go':'go','rs':'rust','java':'java','c':'c','cpp':'cpp','h':'c','hpp':'cpp','php':'php','swift':'swift','kt':'kotlin','scala':'scala','r':'r','toml':'toml','ini':'ini','cfg':'ini','conf':'ini','env':'ini','txt':'plaintext','log':'plaintext'};
|
||||
if(mime.startsWith('text/')||codeExts.includes(ext)){
|
||||
try{const r=await fetch(url);const text=await r.text();
|
||||
if(ext==='md'||ext==='markdown'){this.blocks=this.md2b(text);}
|
||||
else{this.blocks=[this.mkB('code',text,{language:langMap[ext]||'Plain Text'})];}
|
||||
this.render();
|
||||
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
|
||||
return;
|
||||
}
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider'||b.type==='image'||b.type==='embed')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
|
||||
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
||||
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
|
||||
toggleFavorite(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const m=this.favorited?'DELETE':'POST';
|
||||
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;this.showToast(this.favorited?'Added to favorites':'Removed from favorites');
|
||||
if(window.appState&&window.appState.refreshFavorites)window.appState.refreshFavorites();
|
||||
}).catch(()=>{this.showToast('Failed to toggle favorite');});
|
||||
},
|
||||
toggleActivityOpen(){
|
||||
if(!this.activityOpen){this.activityOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.activityOpen=false;}
|
||||
},
|
||||
toggleShareOpen(){
|
||||
if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.shareOpen=false;}
|
||||
},
|
||||
toggleMoreOpen(){
|
||||
if(!this.moreOpen){this.moreOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
|
||||
else{this.moreOpen=false;}
|
||||
},
|
||||
_justOpened:false,
|
||||
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
|
||||
saveShare(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
|
||||
},
|
||||
async copyPageLink(){
|
||||
console.log('copyPageLink invoked');
|
||||
const url = this.pageUrl;
|
||||
const fallback = () => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
document.execCommand('copy'); document.body.removeChild(ta);
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.showToast('Link copied to clipboard');
|
||||
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
|
||||
},
|
||||
showToast(msg){
|
||||
this.toastMsg = msg; this.toastVisible = true;
|
||||
clearTimeout(this._toastTimer);
|
||||
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
||||
},
|
||||
publishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.is_published) {
|
||||
self.pagePublished = true;
|
||||
self.publishedUrl = window.location.origin + '/p/' + d.publish_slug;
|
||||
self.showToast('Published to web — ' + self.publishedUrl);
|
||||
} else {
|
||||
self.showToast(d.detail || 'Publish failed');
|
||||
}
|
||||
}).catch(function(){ self.showToast('Publish failed'); });
|
||||
},
|
||||
unpublishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (!d.is_published) {
|
||||
self.pagePublished = false;
|
||||
self.publishedUrl = '';
|
||||
self.showToast('Unpublished');
|
||||
} else {
|
||||
self.showToast(d.detail || 'Unpublish failed');
|
||||
}
|
||||
}).catch(function(){ self.showToast('Unpublish failed'); });
|
||||
},
|
||||
shareInvite() {
|
||||
var self = this;
|
||||
if (!this.inviteEmail.trim()) return;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
},
|
||||
loadShares() {
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
|
||||
self.accessList = d.shares || [];
|
||||
}).catch(function(){ self.accessList = []; });
|
||||
},
|
||||
removeShare(sid) {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Remove failed'); }
|
||||
}).catch(function(){ self.showToast('Remove failed'); });
|
||||
},
|
||||
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
||||
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
||||
exportPage(){
|
||||
this.moreOpen=false;
|
||||
// Export blocks as Markdown
|
||||
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n';
|
||||
for (var i = 0; i < this.blocks.length; i++) {
|
||||
var b = this.blocks[i];
|
||||
md += blocksToMarkdown(b) + '\n\n';
|
||||
}
|
||||
var blob = new Blob([md], {type: 'text/markdown'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = (this.pageTitle || 'export') + '.md';
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
this.showToast('Exported as Markdown');
|
||||
},
|
||||
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
movePage(){
|
||||
this.moreOpen=false;
|
||||
this.moveOpen = true;
|
||||
this.moveLoading = true;
|
||||
var self = this;
|
||||
fetch('/api/workspaces')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
self.moveWorkspaces = d.workspaces || [];
|
||||
self.moveLoading = false;
|
||||
})
|
||||
.catch(function(){ self.moveLoading = false; });
|
||||
},
|
||||
doMove(wsId) {
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.status === 'ok') {
|
||||
self.showToast('Moved to workspace');
|
||||
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
|
||||
} else {
|
||||
self.showToast(d.detail || 'Move failed');
|
||||
}
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
|
||||
onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
return;
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
|
||||
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
||||
if(e.key==='Enter'){
|
||||
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
||||
e.preventDefault();
|
||||
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
|
||||
if(!before.trim()&&!after.trim()){
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
},
|
||||
checkMd(idx){
|
||||
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
|
||||
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
||||
this.sync();
|
||||
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
||||
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
||||
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
|
||||
},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
|
||||
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
save(){
|
||||
if(this.fileData)return;
|
||||
if(this.saving)return;
|
||||
this.sync();
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
}).catch(()=>{this.saving=false;});
|
||||
},
|
||||
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
|
||||
};
|
||||
}
|
||||
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
|
||||
function blocksToMarkdown(b) {
|
||||
var c = b.content || '';
|
||||
switch(b.type) {
|
||||
case 'heading_1': return '# ' + c;
|
||||
case 'heading_2': return '## ' + c;
|
||||
case 'heading_3': return '### ' + c;
|
||||
case 'heading_4': return '#### ' + c;
|
||||
case 'bulleted_list': return '- ' + c;
|
||||
case 'numbered_list': return '1. ' + c;
|
||||
case 'to_do': return (b.checked ? '- [x] ' : '- [ ] ') + c;
|
||||
case 'quote': return '> ' + c;
|
||||
case 'divider': return '---';
|
||||
case 'code': return '```' + (b.language || '') + '\n' + c + '\n```';
|
||||
case 'toggle': return '## ' + c;
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
|
||||
{% include "_page_editor_scripts.html" %}
|
||||
{% include "_page_editor_realtime.html" %}
|
||||
{% include "_database_table_scripts.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %} {% block page_icon %}{{ fd_icon("file",14) }}{% endblock %} {% block
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
// Initialize database table from server-rendered data
|
||||
window.__DB_PAGE_ID = {{ page.id }};
|
||||
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
|
||||
</script>
|
||||
{% include "_database_table_scripts.html" %}
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user