Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401d0b17ca | ||
|
|
5951c707eb | ||
|
|
f2f2f3209e | ||
|
|
f2e5684e4e | ||
|
|
b56b181c3e | ||
|
|
2fceed0da2 | ||
|
|
436898d86d | ||
|
|
e0237e576f | ||
|
|
189ed5bbca | ||
|
|
ce0d561ade | ||
|
|
95bc861cdb | ||
|
|
ea19d1d050 | ||
|
|
7f998faf7b | ||
|
|
0b251649e5 | ||
|
|
13d5f8625a | ||
|
|
d707a6850a | ||
|
|
f1ce34a8a6 | ||
|
|
b5207216f1 | ||
|
|
62620ef884 | ||
|
|
b0cb3a3923 | ||
|
|
e6afa004d0 | ||
|
|
bf3d1ac0cc | ||
|
|
f9da57c9e0 | ||
|
|
88e4ae1db8 | ||
|
|
a0db4d6e65 | ||
|
|
fb14c7e709 | ||
|
|
0d475c3d2d | ||
|
|
98af112ba1 | ||
|
|
7096707b3e | ||
|
|
9ab47d8113 | ||
|
|
41c1d315d3 | ||
|
|
4038e9bdad | ||
|
|
334a937507 | ||
|
|
c7d4fd901f | ||
|
|
7794a03934 | ||
|
|
9dfc38706c | ||
|
|
d4adf89db5 | ||
|
|
055956a351 | ||
|
|
3b3e95e23a | ||
|
|
37337a5de7 | ||
|
|
e8797afa05 | ||
|
|
3b00cbc371 | ||
|
|
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 |
@@ -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,30 @@ 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
|
||||
|
||||
# ── Public API v2 (v6.3.0) ──
|
||||
# PUBLIC_API_INSECURE_OK=true autorise le token de dev fd-public-key (jamais en prod).
|
||||
PUBLIC_API_INSECURE_OK=false
|
||||
API_V2_RATE_LIMIT_PER_TOKEN=300
|
||||
|
||||
# ── 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
|
||||
|
||||
+6
-3
@@ -109,8 +109,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │ ├─ 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 │ │
|
||||
│ │ ├─ public_api.py — /api/v1 Public API v1 │ │
|
||||
│ │ ├─ api_v2.py — /api/v2 Public API v2 │ │
|
||||
│ │ │ — Bearer + scopes, CRUD complet │ │
|
||||
│ │ ├─ web_clipper.py — /api/v2/web-clipper Web Clipper │ │
|
||||
│ │ ├─ permissions.py — /api/v2 (ACL) Permissions │ │
|
||||
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||
@@ -1705,6 +1708,6 @@ docker compose restart flowdeck
|
||||
- **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
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+1646
-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", "*"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,10 +49,12 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
### API & Intégrations (v6.3)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
- **PWA**: manifest.json + service worker, offline support
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
@@ -85,7 +87,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+588
-79
@@ -244,38 +244,104 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [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
|
||||
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
|
||||
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 5](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
|
||||
### v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)
|
||||
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
|
||||
|
||||
- [ ] **Tables `sprints`, `sprint_pages`** — sprints agiles
|
||||
- [ ] **API sprints** — CRUD + assignation pages, vélocité
|
||||
- [ ] **Sprint board** — Current Sprint / Planning / Backlog
|
||||
- [ ] **Burndown chart** — vélocité, points complétés vs restants
|
||||
- [ ] **My Tasks** — agrégation cross-databases (toutes les task DBs)
|
||||
- [ ] **My Tasks API** — /my-tasks?view=all|today|overdue|upcoming
|
||||
- [ ] **My Tasks UI** — groupes par collection, status badges, filtres
|
||||
- [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
|
||||
### 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.7.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.8.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.9.0 — FlowDeck Agent (Agent IA natif)
|
||||
- [x] **Export Markdown** — avec images et sous-pages (récursif)
|
||||
- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système)
|
||||
- [x] **Export HTML** — site statique standalone (document autonome + site .zip)
|
||||
|
||||
Détails livrés :
|
||||
- Service serveur `app/services/export.py` : conversion blocs → Markdown / HTML / PDF
|
||||
- 4 formats exposés : `/api/export/markdown|html|pdf|site/{page_id}`
|
||||
- Sous-pages incluses récursivement (Markdown et site)
|
||||
- UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
|
||||
- PDF généré via `xhtml2pdf` (pip) — aucun lib système requise
|
||||
- 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404)
|
||||
|
||||
### v4.7.1 — Fix UI Export / Share / More ✅ (2026-09-03)
|
||||
- [x] **Share / More / Activity / Move-to popovers** — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
|
||||
- [x] **Sous-menu Export** — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
|
||||
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
|
||||
- [x] Vérifié Playwright headless + 190/190 tests
|
||||
|
||||
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
|
||||
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
|
||||
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
|
||||
|
||||
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
|
||||
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
|
||||
- [x] **Émojis PDF** — passage du moteur à **WeasyPrint** (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
|
||||
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
|
||||
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
|
||||
|
||||
### v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)
|
||||
- [x] **Bloc `table` éditable dans l'éditeur** — insertion via `/table` (commandes slash, section BASIC), tableau 3×3 avec en-tête, cellules éditables au clic, auto-sauvegarde.
|
||||
- [x] **Lignes** — bouton `+ Row` sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
|
||||
- [x] **Colonnes** — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
|
||||
- [x] **Import Markdown (GFM)** — `md2b` transforme les tableaux pipe en bloc `table` au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc `table`.
|
||||
|
||||
### v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)
|
||||
- [x] **« + Row » (bas) corrigé** — l'insertion de ligne utilisait `splice(index, ligne)` (mauvaise signature) → aucune ligne ajoutée ; désormais `splice(index, 0, ligne)`.
|
||||
- [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »).
|
||||
- [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`.
|
||||
|
||||
### v4.9.0 — Collaboration ✅ (2026-09-04)
|
||||
> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**.
|
||||
> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md)
|
||||
|
||||
- [x] **Inline comments** — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table `comments` étendue (`target_type`/`target_id`/`anchor_block_id`/`anchor_start`/`anchor_end`) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar
|
||||
- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu)
|
||||
- [x] **Email notifications** — service SMTP (`app/services/mailer.py` + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config `.env` (`SMTP_*`, `APP_BASE_URL`) ; repli no-op sans SMTP
|
||||
- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
|
||||
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
|
||||
- [x] **208 tests passent** (+9 v4.9.0)
|
||||
|
||||
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
|
||||
|
||||
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
|
||||
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
|
||||
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
|
||||
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
|
||||
|
||||
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
|
||||
|
||||
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
|
||||
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
|
||||
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
|
||||
|
||||
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
|
||||
|
||||
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
|
||||
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
|
||||
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
|
||||
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
|
||||
|
||||
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
|
||||
|
||||
@@ -296,16 +362,16 @@ Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agen
|
||||
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
|
||||
|
||||
#### Fonctionnalités clés
|
||||
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
|
||||
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
|
||||
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
|
||||
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
|
||||
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
|
||||
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
|
||||
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
|
||||
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
|
||||
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
|
||||
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama)
|
||||
- [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)
|
||||
```
|
||||
@@ -339,7 +405,7 @@ app/
|
||||
|
||||
#### 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
|
||||
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
|
||||
@@ -350,65 +416,506 @@ app/
|
||||
- Budget tokens max par conversation (500k tokens)
|
||||
- Timeout 5 minutes par run
|
||||
|
||||
### v5.0.0 — Command Palette & Recherche
|
||||
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
|
||||
- [ ] **Quick actions** — navigation, création, commandes
|
||||
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
|
||||
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
|
||||
|
||||
### v5.1.0 — Automations
|
||||
- [ ] **Database automations** — if-this-then-that
|
||||
- [ ] **Buttons** — cliquables déclenchant actions
|
||||
- [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.2.0 — Infrastructure & Polish
|
||||
> **Objectif** : Qualité de code, design system, backup, CI/CD.
|
||||
> **Items issus de l'ancien docs/ROADMAP.md (v3.0)**
|
||||
### 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**
|
||||
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés)
|
||||
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts
|
||||
- [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**
|
||||
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur
|
||||
- [ ] **Sessions actives** — voir et révoquer les sessions
|
||||
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
|
||||
- [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**
|
||||
- [ ] **Migrations versionnées** — Alembic ou table `schema_version`
|
||||
- [ ] **Backup automatique** — cron daily → fichier daté
|
||||
- [ ] **Index manquants** — `users.email`, `forge_connections.user_id`
|
||||
- [ ] **Linting** — ruff (Python), eslint (JS)
|
||||
- [ ] **Tests parallèles** — pytest-xdist
|
||||
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
||||
- [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**
|
||||
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
|
||||
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure)
|
||||
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
||||
- [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] **143 tests** — cible 100+ atteinte ✅
|
||||
- [ ] Tests d'intégration auth (OAuth mock)
|
||||
- [ ] Tests des adapters forge (mock HTTP)
|
||||
- [ ] Tests multi-user (permissions croisées)
|
||||
- [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
|
||||
|
||||
### v5.3.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)
|
||||
**✅ Validation (2026-09-11)** :
|
||||
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
|
||||
- `pytest -q` (suite complète) → **397 passed**
|
||||
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
|
||||
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
|
||||
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
|
||||
|
||||
**Complétion du 2026-09-11** :
|
||||
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
|
||||
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
|
||||
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
|
||||
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
|
||||
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
|
||||
|
||||
### v5.3.0 — Database Avancée ✅ (2026-09-06)
|
||||
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
|
||||
|
||||
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
|
||||
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
|
||||
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
|
||||
|
||||
### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)
|
||||
> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables
|
||||
> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**.
|
||||
|
||||
- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`)
|
||||
- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-<id>` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
|
||||
- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
|
||||
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
|
||||
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
|
||||
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
|
||||
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
|
||||
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
|
||||
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
|
||||
|
||||
- [x] **WebSocket gateway** — endpoint `WS /ws/pages/{id}`, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
|
||||
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
|
||||
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
|
||||
- [x] **Merge de modifications** — diffusion des ops de blocs insert/update/delete/move avec **last-write-wins par bloc** + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
|
||||
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
|
||||
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
|
||||
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
|
||||
|
||||
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
|
||||
- [x] **Duplicates** — `POST /board/api/pages/{id}/duplicate` (copie profonde des blocs) + `POST /db/{id}/duplicate` (vues + propriétés + pages) ; entrées « Duplicate » dans les menus `...`
|
||||
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
|
||||
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
|
||||
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
|
||||
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
|
||||
|
||||
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
|
||||
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
|
||||
|
||||
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
|
||||
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
|
||||
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
|
||||
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
|
||||
|
||||
Détails livrés :
|
||||
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
|
||||
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
|
||||
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
|
||||
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
|
||||
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
|
||||
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
|
||||
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
|
||||
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
|
||||
|
||||
### v5.6.0 — Import de données (étendu) ✅ (2026-09-13)
|
||||
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
|
||||
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
|
||||
> (Markdown/CSV/Notion, déjà dans v5.4.0). **COMPLETED**.
|
||||
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
|
||||
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
|
||||
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
|
||||
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
|
||||
|
||||
#### Phase 0 — Socle d'import unifié (prérequis) ✅
|
||||
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
|
||||
- [x] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [x] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [x] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [x] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [x] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [x] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [x] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [x] **Tests** — harnais d'import + fixtures par source
|
||||
- [x] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
|
||||
|
||||
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort ✅
|
||||
- [x] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [x] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [x] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [x] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [x] **Google Keep** — Takeout JSON + HTML
|
||||
- [x] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅
|
||||
- [x] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [x] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [x] **Google Sheets** — export CSV/XLSX
|
||||
- [x] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
#### Phase 3 — Documents & bureautique — effort moyen ✅
|
||||
- [x] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [x] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [x] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [x] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers ✅
|
||||
- [x] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [x] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [x] **Pocket** (CSV/HTML)
|
||||
- [x] **Readwise** (highlights CSV/Markdown)
|
||||
- [x] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [x] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [x] **OPML** (flux/outlines)
|
||||
- [x] **Standard Notes / autres**
|
||||
|
||||
#### Phase 5 — Durcissement & finition ✅
|
||||
> **Objectif** : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).
|
||||
- [x] **Import incrémental / re-sync** — modes `skip` / `update` (upsert des pages + lignes par titre) / `duplicate`, via `import_items`
|
||||
- [x] **Import de dépôt forge** — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
|
||||
- [x] **Import par URL / web clipper** — `POST /api/import/url` : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
|
||||
- [x] **Lot multi-fichiers + file d'attente** — `POST /api/import/run-batch` + file d'attente UI (statut par fichier)
|
||||
- [x] **Relations Notion** — `POST /api/import/relations/resolve` : colonnes texte référençant une autre collection → propriétés `relation` (ids de pages) ; auto-exécuté après un import Notion
|
||||
- [x] **Reprise / erreurs partielles + rapport exportable** — import qui continue par page en cas d'erreur (`status: partial`, liste `errors`) ; rapport JSON téléchargeable (`GET /api/import/jobs/{id}/report` + bouton UI)
|
||||
- [x] **Valeurs de propriétés par id** — correction : les lignes importées stockent les valeurs par id de propriété (rendu correct dans les vues DB)
|
||||
|
||||
#### Priorisation
|
||||
| Ordre | Phase | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
|
||||
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
|
||||
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
|
||||
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
|
||||
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. **COMPLETED**.
|
||||
|
||||
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
|
||||
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
|
||||
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
|
||||
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
|
||||
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
|
||||
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
|
||||
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
|
||||
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
|
||||
|
||||
Détails livrés :
|
||||
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
|
||||
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
|
||||
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
|
||||
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
|
||||
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
|
||||
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
|
||||
|
||||
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
|
||||
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
|
||||
|
||||
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
|
||||
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
|
||||
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
|
||||
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
|
||||
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
|
||||
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
|
||||
|
||||
Détails livrés :
|
||||
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
|
||||
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
|
||||
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
|
||||
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
|
||||
- **Version** — 5.11.7
|
||||
|
||||
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
|
||||
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
|
||||
|
||||
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
|
||||
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
|
||||
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
|
||||
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
|
||||
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
|
||||
- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0
|
||||
|
||||
---
|
||||
|
||||
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
|
||||
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
|
||||
> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**.
|
||||
|
||||
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
|
||||
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
|
||||
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
|
||||
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
|
||||
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
|
||||
|
||||
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
|
||||
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
|
||||
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
|
||||
|
||||
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
|
||||
- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
|
||||
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
|
||||
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
|
||||
- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.14.0 — Synced blocks ✅ (2026-09-15)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
> plusieurs pages, édité une fois). **COMPLETED**.
|
||||
|
||||
- [x] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [x] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
|
||||
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [x] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
### v5.15.0 — Webhooks v2 ✅ (2026-09-21)
|
||||
> **Objectif** : webhooks sécurisés, fiables et complets pour les intégrations tierces. **COMPLETED**.
|
||||
|
||||
- [x] **Signature HMAC SHA-256** — header `X-FlowDeck-Signature` sur chaque payload, vérification côté receveur
|
||||
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
|
||||
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
|
||||
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
|
||||
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration)
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
|
||||
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
|
||||
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
|
||||
|
||||
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
|
||||
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
|
||||
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
|
||||
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
|
||||
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
|
||||
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
|
||||
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
|
||||
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
|
||||
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
|
||||
- [x] **Doc** — section `/help` « Offline mode (PWA) »
|
||||
|
||||
---
|
||||
|
||||
## v6.1.0 — Granular Permissions ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : page-level, collection-level & property-level ACL + groupes + audit. **COMPLETED**.
|
||||
|
||||
- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
|
||||
- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
|
||||
- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
|
||||
- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
|
||||
- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
|
||||
- [x] **Guards** — `board.py` (`GET/PUT /board/api/pages/{id}`) + `collections.py` (`delete collection`, `GET/PUT/DELETE page`, `properties`), 403/404 conformes, is_admin/owner bypass + `_session_user()` (no admin fallback)
|
||||
- [x] **PermissionManager** — `app/services/permission_manager.py` étendu : `_explicit_grant_role()` (best rank user+groups), `get_page/collection_permission()`, `can_view/edit_page|collection`, `can_view/edit_property()`, `get_visible_properties()`, groups, `log_permission_change()`, cache 60s + `invalidate()`
|
||||
- [x] **Audit** — table `permission_audit_log` (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, `GET /api/v2/audit/permissions` (owner/admin only, limit 500)
|
||||
- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
|
||||
- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
|
||||
|
||||
## v6.2.0 — Web Clipper : extension navigateur ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : capturer n'importe quelle page web en page FlowDeck (article, sélection, bookmark, screenshot) depuis une extension Manifest V3 + API directe. **COMPLETED**.
|
||||
|
||||
- [x] **Extension Manifest V3** — `extension/` + `static/extension/` (manifest, `background.js`, `content.js`, `popup.html/js`, `clipper.css`, icônes 16/32/48/128, `flowdeck-clipper.zip` servi à `/static/extension/`)
|
||||
- [x] **4 types de capture** — article (HTML complet → `sanitize_html` + `html_to_blocks`), sélection, bookmark (carte OG v5.5.0), screenshot (base64) ; cap 10 MB / 200 blocs
|
||||
- [x] **Serveur** — `POST /api/v2/web-clipper/clip`, `GET /status`, `POST /auth/verify` (register device → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` + page HTML `GET /extensions` ; auth triple (session OU Bearer `api_tokens` OU Bearer `extension_devices` OU legacy `user_tokens`), rate-limit 50/h/device
|
||||
- [x] **Tables migration 19** — `extension_devices`, `extension_clips` + index `idx_ext_*`
|
||||
- [x] **Settings UI** — onglet Extensions (devices, clips count, revoke)
|
||||
- [x] **16 tests** `tests/test_web_clipper.py` ; `VERSION` 6.2.0 ; wiring `app/main.py:50,158`
|
||||
|
||||
### v6.2.1 — Web Clipper polish ✅ (2026-09-20)
|
||||
|
||||
- [x] **Bouton flottant rond transparent draggable** — toggle d'affichage persistant, `clipper:clipped` refresh auto sidebar
|
||||
- [x] **Fix bloc bookmark** — `create_page_from_clip()` émet un bloc `bookmark` fidèle (OG + `embed_src` résolu), rendu/correct en éditeur + `/p/<slug>` + exports
|
||||
- [x] Fix `duplicate inner` SyntaxError dans `_page_editor_scripts.html`
|
||||
- [x] `flowdeck-clipper.zip` régénéré
|
||||
|
||||
## v6.3.0 — API publique complète v2 ✅ (2026-09-21)
|
||||
|
||||
> **Objectif** : REST API documentée OpenAPI, CRUD complet, un seul chemin de code (wrappers sur les services internes). Parité `docs/API_GUIDE_V6.md` §4 (~80 endpoints) + scopes hiérarchiques `read < write < admin`. **COMPLETED**.
|
||||
> **Route** : `feat/v6-api-v2` → `develop` → `main` — livraison **en une fois** (tous domaines).
|
||||
> **Doc** : [`docs/API_GUIDE_V6.md`](/docs/API_GUIDE_V6.md) · OpenAPI : `/docs` + `docs/openapi-v2.json` (402 chemins)
|
||||
|
||||
#### Phase 0 — Roadmap & doc catch-up ✅
|
||||
|
||||
- [x] Tagguer v6.2.0/v6.2.1 dans `CHANGELOG.md` + `ROADMAP.md` + `docs/V6_Web_Clipper.md` → `COMPLETED`
|
||||
- [x] Détailler v6.3.0 phases 1-8 dans `ROADMAP.md` (plan gelé)
|
||||
|
||||
#### Phase 1 — Migrations socles (v20) ✅
|
||||
|
||||
- [x] `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'`, `expires_at TIMESTAMP` (idempotent, rétro-compat)
|
||||
- [x] `webhook_deliveries` : `id, webhook_id FK, status, http_code, error, duration_ms, attempt, created_at`
|
||||
- [x] `api_audit_log` : `id, user_id, token_id, action, resource_type, resource_id, ip, created_at`
|
||||
- [x] `idempotency_keys` : `key TEXT PRIMARY KEY, user_id, response_json, created_at`
|
||||
|
||||
#### Phase 2 — Helpers & auth v2 unifiée (scopes hiérarchiques) ✅
|
||||
|
||||
- [x] `app/config.py` : `public_api_insecure_ok: bool = False` — `fd-public-key` accepté seulement si `True` (dev local)
|
||||
- [x] `app/services/api_v2_helpers.py` : `parse_pagination()` (+`X-Total-Count`), `to_iso8601()`, handler RFC 7807 `application/problem+json`, `require_scope()` (hiérarchie `admin ⊇ write ⊇ read`), `resolve_bearer_token()` / `get_bearer_user()` (hash sha256, `revoked` + `expires_at` + scopes, `last_used_at`, `extension_devices`)
|
||||
- [x] Auth Bearer unifié partagé (clipper + legacy `user_tokens` supportés) ; handler d'erreurs unifié sur `StarletteHTTPException`
|
||||
|
||||
#### Phase 3 — Tokens CRUD v2 ✅
|
||||
|
||||
- [x] `POST /api/v2/tokens` (`name, scopes, expires_at`) → `fd_{urlsafe(32)}` hashé, montré une fois
|
||||
- [x] `GET /api/v2/tokens` (prefix only), `DELETE /api/v2/tokens/{id}` (revoke), `POST /api/v2/tokens/{id}/rotate`
|
||||
|
||||
#### Phase 4 — Wrappers read (pagination/filtres/tri/fields) ✅
|
||||
|
||||
- [x] `app/routers/api_v2.py` (`prefix="/api/v2"`, tag `api-v2`) — `GET /collections`, `GET /collections/{id}`, `GET /pages/{id}`, `GET /collections/{id}/pages?filter[]=&sort=&fields=&query=`, `GET /search?query=&workspace_id=&type=` (FTS5), header `X-Total-Count`, `filter[]` AND, `sort=prop/-prop`
|
||||
|
||||
#### Phase 5 — Wrappers write critiques (`Idempotency-Key`) ✅
|
||||
|
||||
- [x] Collections : `POST/GET/PATCH/DELETE /collections/{id}` + `/linked`, `/task`, `/sources`
|
||||
- [x] Pages : `POST/GET/PATCH/DELETE /pages/{id}` + `/restore`, `/move`, `/sub-items`, `/dependencies`
|
||||
- [x] Properties : `GET/POST /collections/{id}/properties`, `PATCH/DELETE /properties/{id}`, `POST .../relation`, `evaluate-formula`, `compute-rollup`
|
||||
- [x] Views/Dashboards/Comments/Notifications/Favorites/Tags/Recents/Sharing/History/Sprints/Templates/Export/Workspaces/Users/Admin — regroupés par ressource
|
||||
|
||||
#### Phase 6 — Webhooks v2 — CRUD simple ✅
|
||||
|
||||
- [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping)
|
||||
- [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique)
|
||||
- [x] *(reporté v6.4, livré plus tôt)* **v5.15.0** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events (Webhooks v2)
|
||||
|
||||
#### Phase 7 — Forges & ressources restantes ✅
|
||||
|
||||
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
|
||||
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
|
||||
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt)
|
||||
|
||||
#### Phase 8 — OpenAPI, tests & docs ✅
|
||||
|
||||
- [x] `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins)
|
||||
- [x] `tests/test_public_api_v2.py` — **24 tests** (auth scopes, pagination, filtres, RFC 7807, idempotency, webhooks deliveries, CRUD multi-domaines)
|
||||
- [x] Vérif `ruff check app tests` + `pytest -n auto` → **668 verte**
|
||||
|
||||
## v6.4.0 — Realtime editing (production) ✅ (2026-09-22)
|
||||
|
||||
> **Objectif** : passer le realtime v5.13.0 en « production » — résolution de
|
||||
> conflits au-delà du last-write-wins + édition à grande échelle (broadcast non
|
||||
> bloquant, plusieurs rooms/pages, observabilité). **COMPLETED**.
|
||||
|
||||
#### Conflits au-delà du LWW
|
||||
|
||||
- [x] **Merge à 3 voix (diff3-lite)** — nouveau service `app/services/realtime_merge.py` : `merge_text_3way()` (merge de caractères) + `merge_block_3way()` (merge champ-par-champ), fonctions pures et testées sans WebSocket ni base
|
||||
- [x] **Régions disjointes conservées** — deux utilisateurs tapant à des endroits différents du *même* bloc voient leurs deux saisies survivre (au lieu d'écraser l'une par l'autre) ; ordre d'arrivée indifférent
|
||||
- [x] **Chevauchement réel → LWW par champ + drapeau** — un conflit n'est plus « tout le bloc perdu » mais limité au champ concerné ; `conflict: true` renvoyé dans l'`ack` et le broadcast
|
||||
- [x] **Protocole `base`** — le client embarque dans chaque `update` la version du bloc dont dérive sa saisie ; le serveur fait `merge_block_3way(base, current, incoming)` ; sans `base` → LWW historique (rétro-compat ancien client)
|
||||
- [x] **Adoption côté client** — l'`ack` renvoie le bloc fusionné ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit
|
||||
- [x] **Broadcast du résultat fusionné** — le serveur diffuse toujours le bloc final fusionné (jamais la proposition brute) pour convergence garantie de tous les clients
|
||||
|
||||
#### Échelle & robustesse
|
||||
|
||||
- [x] **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room
|
||||
- [x] **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), tout en préservant l'ordre des messages importants
|
||||
- [x] **Déconnexion des clients trop lents** — file pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects` (évite qu'une room entière stagne sur un pair mortel)
|
||||
- [x] **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / 10 s) ; au-delà, réponse `ack stale` sans application
|
||||
- [x] **Fix fuite de rooms** — une connexion 4404 n'enregistre plus de `Room` orpheline en mémoire ; `room_state()` sur page inexistante retourne un dict vide au lieu de planter sur `None`
|
||||
- [x] **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions, ops, merges, conflits, déconnexions lentes + détail par page
|
||||
|
||||
Détails livrés :
|
||||
- `app/services/realtime_merge.py` — merge 3-voix (nouveau, ~170 lignes)
|
||||
- `app/services/realtime_server.py` — `RTConn` (file + writer + budget), `_apply()` avec merge, `_evict_slow()`, `stats()`, fix fuites
|
||||
- `app/routers/realtime.py` — endpoint `GET /api/realtime/stats`
|
||||
- `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit
|
||||
- **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés
|
||||
- `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.4.0
|
||||
|
||||
---
|
||||
|
||||
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
|
||||
> **Objectif** : passer les synced blocks en « production » (résolution +
|
||||
> propagation fiables partout) et les faire vivre dans les databases/vues —
|
||||
> le « reste en v6 » du point **v5.14.0**. **COMPLETED**.
|
||||
|
||||
#### Contenu des lignes de database (le point « databases »)
|
||||
- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet
|
||||
- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu
|
||||
- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar)
|
||||
- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}`
|
||||
- [x] **Titres synchronisés bidirectionnellement** — renommer la ligne (`PUT /db/pages/{id}/api`) → page contenu ; renommer dans l'éditeur (`PUT /board/api/pages/{id}`, `POST .../blocks`, `PUT /api/pages/{id}/rename`) → ligne
|
||||
- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade)
|
||||
- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash
|
||||
|
||||
#### Résolution serveur + propagation (le point « production »)
|
||||
- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi
|
||||
- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles)
|
||||
- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()`
|
||||
- [x] **État « deleted »** — `resolve_synced_block()` marque `_synced_deleted` (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel)
|
||||
- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `<p>`) + état deleted
|
||||
- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture)
|
||||
|
||||
#### Tests
|
||||
- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2)
|
||||
- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.5.0 (VERSION + `app/main.py`)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
|
||||
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
|---------|----------|------|
|
||||
| Webhooks sortants | `services/webhook_outbound.py`, `routers/workspace.py` (`/workspace/webhooks`) | CRUD + dispatch d'événements — base pour automations/API publique |
|
||||
| API publique + tokens | `routers/public.py` / `public_api.py`, test `test_public_api_token` | À formaliser dans v5.2.0 et documenter pour v6.0.0 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
|
||||
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
@@ -418,9 +925,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.0 ✅ v4.5.0 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
|
||||
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
|
||||
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur)
|
||||
```
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ✅
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced PWA · Perms
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks Clipper · API v2
|
||||
Collaboration Realtime, de bloc (undo/ (embed, + DB
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
|
||||
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
| v3.0 | Auth locale, Multi-Forge, Standalone | ✅ | — |
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -58,5 +61,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+53
-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"
|
||||
@@ -41,6 +43,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
# Public API v2 (v6.3.0)
|
||||
public_api_insecure_ok: bool = False # if True, fd-public-key is accepted (dev only)
|
||||
api_v2_rate_limit_per_token: int = 300 # req/min per token for /api/v2
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:////data/flowdeck.db"
|
||||
|
||||
@@ -48,6 +54,51 @@ class Settings(BaseSettings):
|
||||
sync_interval: int = 60
|
||||
gitea_cache_ttl: int = 30
|
||||
|
||||
# Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
|
||||
backup_enabled: bool = True
|
||||
backup_dir: str = "/data/backups"
|
||||
backup_interval_hours: int = 24
|
||||
backup_keep: int = 30
|
||||
|
||||
# Forge projects sync (v5.2.0) — periodic refresh of `projects` table
|
||||
project_sync_enabled: bool = True
|
||||
project_sync_interval_hours: int = 1
|
||||
|
||||
# Reminders (v5.8.0) — background scan for due date reminders
|
||||
reminders_enabled: bool = True
|
||||
reminder_scan_interval_seconds: int = 60
|
||||
|
||||
# Webhooks outbound (v6.4.0) — retry of failed deliveries
|
||||
webhook_retry_enabled: bool = True
|
||||
webhook_retry_interval_seconds: int = 60
|
||||
|
||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||
# email notifications are skipped (only in-app notifications are delivered).
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "FlowDeck <[email protected]>"
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
llm_provider: str = "offline" # any id from llm_client.PROVIDERS
|
||||
# (openai, anthropic, mistral, cohere,
|
||||
# google, groq, deepseek, openrouter,
|
||||
# nvidia, together, perplexity, xai,
|
||||
# qwencloud, minimax, morph, fireworks,
|
||||
# cerebras, sambanova, chutes, xiaomi,
|
||||
# sealion, sensenova, ollama, offline)
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
|
||||
agent_max_iterations: int = 12
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -442,12 +442,18 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
shared_with_email TEXT DEFAULT '',
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 4) recents table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recents (
|
||||
@@ -487,6 +493,12 @@ def init_db():
|
||||
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:
|
||||
@@ -562,6 +574,260 @@ def init_db():
|
||||
""")
|
||||
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():
|
||||
|
||||
+141
-27
@@ -1,22 +1,56 @@
|
||||
"""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.exceptions import HTTPException as _StarHTTPException
|
||||
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,
|
||||
sync,
|
||||
webhooks,
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
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.imports import page_router as import_page_router
|
||||
from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -39,15 +73,58 @@ 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())
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
task.cancel()
|
||||
for task in _tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
version="6.5.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -61,6 +138,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,21 +151,40 @@ app.include_router(gitea_router)
|
||||
app.include_router(github_router)
|
||||
app.include_router(public_api.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(onboarding.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
return {
|
||||
"name": "FlowDeck",
|
||||
"short_name": "FlowDeck",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#191919",
|
||||
"theme_color": "#191919",
|
||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
}
|
||||
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
|
||||
|
||||
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||
@@ -95,8 +193,9 @@ async def pwa_manifest():
|
||||
@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."""
|
||||
from fastapi.responses import JSONResponse
|
||||
import secrets
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
token = secrets.token_hex(32)
|
||||
response = JSONResponse({"csrf_token": token})
|
||||
response.set_cookie(
|
||||
@@ -150,12 +249,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
raised exceptions and route-miss 404s.
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||
|
||||
@@ -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", "/api/csrf-token"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
|
||||
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,985 @@
|
||||
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
|
||||
|
||||
This replaces the previous "ad-hoc" approach where every new schema change was
|
||||
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
|
||||
table now records the highest applied migration; the full baseline schema
|
||||
(created idempotently by `init_db`) is treated as version 1, and any incremental
|
||||
change is expressed as an ordered, versioned step below and applied exactly once.
|
||||
|
||||
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
|
||||
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
|
||||
``ALTER TABLE``) so it is safe even if partially re-run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The full baseline schema created by `app.db::init_db()` is "version 1".
|
||||
BASELINE_VERSION = 1
|
||||
|
||||
# (version, name, apply_fn). Kept sorted by version at registration time.
|
||||
MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
|
||||
|
||||
|
||||
def register(version: int, name: str) -> Callable:
|
||||
"""Decorator registering a migration in the ordered registry."""
|
||||
if any(v == version for v, _, _ in MIGRATIONS):
|
||||
raise ValueError(f"Duplicate migration version {version}")
|
||||
|
||||
def decorator(fn: Callable[[sqlite3.Connection], None]):
|
||||
MIGRATIONS.append((version, name, fn))
|
||||
MIGRATIONS.sort(key=lambda item: item[0])
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def fts5_available() -> bool:
|
||||
"""True when the bundled SQLite ships the FTS5 extension."""
|
||||
probe = sqlite3.connect(":memory:")
|
||||
try:
|
||||
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
|
||||
return True
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
"""Seal the baseline schema (version 1) and apply pending migrations.
|
||||
|
||||
Returns the resulting schema version.
|
||||
"""
|
||||
_ensure_table(conn)
|
||||
applied = current_version(conn)
|
||||
|
||||
if applied < BASELINE_VERSION:
|
||||
# The pre-existing schema (already created by init_db) is our baseline.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(BASELINE_VERSION, "baseline"),
|
||||
)
|
||||
conn.commit()
|
||||
applied = BASELINE_VERSION
|
||||
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Migrations
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@register(2, "missing indexes")
|
||||
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
|
||||
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
|
||||
for ddl in (
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
|
||||
):
|
||||
conn.execute(ddl)
|
||||
|
||||
|
||||
@register(3, "full-text search (FTS5)")
|
||||
def _migration_fts5(conn: sqlite3.Connection) -> None:
|
||||
"""Create a full-text index over pages (title + content) for the command palette.
|
||||
|
||||
Kept in sync via row-level triggers on the ``pages`` table so page
|
||||
insert/update/delete are reflected immediately. Skips gracefully if the
|
||||
bundled SQLite lacks FTS5 (search then falls back to LIKE).
|
||||
"""
|
||||
if not fts5_available():
|
||||
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
|
||||
return
|
||||
|
||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
|
||||
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
|
||||
# to external-content/contentless FTS5 tables).
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
END
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill the index from any rows that already exist.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@register(5, "automations (v5.1.0 rules engine)")
|
||||
def _migration_automations(conn: sqlite3.Connection) -> None:
|
||||
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
|
||||
condition + action) and clickable buttons that trigger actions.
|
||||
|
||||
``automations`` — the rules (event/cron/button trigger, optional
|
||||
condition JSON, actions JSON, run counters).
|
||||
``automation_runs`` — execution history for auditing and the Settings UI.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
|
||||
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
|
||||
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
|
||||
collection_id INTEGER, -- optional scope (event triggers)
|
||||
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
|
||||
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by INTEGER,
|
||||
last_run_at TIMESTAMP,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automation_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
trigger_source TEXT NOT NULL DEFAULT 'event',
|
||||
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
collection_id INTEGER,
|
||||
page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(6, "v5.2.0: api tokens, user sessions, projects")
|
||||
def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"""v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
|
||||
the forge-agnostic ``projects`` table.
|
||||
|
||||
``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
|
||||
powering the public API (/api/v1) and Settings UI.
|
||||
``user_sessions`` — one row per signed session cookie; revocation here
|
||||
instantly kills the corresponding cookie.
|
||||
``projects`` — normalized project list across forges (builtin/gitea/
|
||||
github) + last sync timestamp for the periodic cron.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'API token',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL DEFAULT '',
|
||||
last_used_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
||||
)
|
||||
|
||||
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE user_sessions (
|
||||
id TEXT PRIMARY KEY, -- session id (cookie payload)
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
||||
)
|
||||
|
||||
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
forge_id TEXT DEFAULT '',
|
||||
clone_url TEXT DEFAULT '',
|
||||
default_branch TEXT DEFAULT '',
|
||||
language TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
last_synced_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(proj_type, owner, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(7, "v5.4.0/v5.5.0: page versions, cover + icon")
|
||||
def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"""v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon).
|
||||
|
||||
``page_versions`` — undoable version snapshots for block-editor pages
|
||||
(NOT tied to ``collection_pages`` like the legacy
|
||||
``page_history`` table). One row per save with the
|
||||
full block list + title so the UI can browse/restore.
|
||||
``pages.cover_url`` — image cover shown above the page title.
|
||||
``pages.page_icon`` — emoji / icon label shown next to the title.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
||||
)
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@register(8, "v5.6.0: custom workspace emojis")
|
||||
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS custom_emojis (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(4, "database templates (icon) + property validation")
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
and the built-in database templates are seeded (idempotently)."""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
||||
if "icon" not in _cols:
|
||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
# Seed built-in templates (idempotent: only missing names are inserted).
|
||||
from app.services.db_templates import SEED_TEMPLATES
|
||||
for tpl in SEED_TEMPLATES:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
|
||||
__import__("json").dumps(tpl.get("schema", []))),
|
||||
)
|
||||
|
||||
|
||||
@register(9, "v5.6.0: import items (dedup) + import jobs")
|
||||
def _migration_import_framework(conn: sqlite3.Connection) -> None:
|
||||
"""Unified import framework (Phase 0).
|
||||
|
||||
``import_items`` — one row per imported page, keyed by workspace + source +
|
||||
external id, so re-importing the same vault is idempotent.
|
||||
``import_jobs`` — background import job status/history for UI polling.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS import_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, source, external_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_import_items_lookup "
|
||||
"ON import_items(workspace_id, source, external_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS import_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
report_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(10, "v5.7.0: property groups, per-user views, row covers")
|
||||
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
"""v5.7.0 — Database Avancée (Pt. 2).
|
||||
|
||||
``collection_properties.group_name`` — groups properties into collapsible
|
||||
sections in the table header (Notion property groups).
|
||||
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
|
||||
a shared/legacy view visible to everyone, otherwise it is personal to a user.
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@register(11, "v5.8.0: reminder log + user timezones")
|
||||
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"""v5.8.0 — Calendrier & Rappels.
|
||||
|
||||
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
|
||||
a reminder fires exactly once even across restarts.
|
||||
``users.timezone`` — personal IANA timezone used for "today" in calendar
|
||||
views and reminder firing (empty = UTC).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS reminder_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
occurrence_date TEXT NOT NULL,
|
||||
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, occurrence_date)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
|
||||
@register(12, "v5.8.0: enrich Meeting notes template")
|
||||
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
|
||||
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
|
||||
Notes text properties. Only refreshed when the row still matches the old
|
||||
built-in schema (user edits are never clobbered)."""
|
||||
import json as _json
|
||||
row = conn.execute(
|
||||
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
try:
|
||||
schema = _json.loads(row[0] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
names = [p.get("name") for p in schema]
|
||||
if "Agenda" in names or "Notes" in names:
|
||||
return
|
||||
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
|
||||
return # customised — leave alone
|
||||
idx = names.index("Action items")
|
||||
schema[idx:idx] = [
|
||||
{"name": "Agenda", "type": "text"},
|
||||
{"name": "Notes", "type": "text"},
|
||||
]
|
||||
conn.execute(
|
||||
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
|
||||
(_json.dumps(schema),),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
|
||||
("Notes de réunion avec participants, agenda, notes et actions.",),
|
||||
)
|
||||
|
||||
|
||||
@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates")
|
||||
def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
"""v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage.
|
||||
|
||||
``pages.is_locked`` — read-only page (locker/admin can unlock).
|
||||
``pages.locked_by`` — user that locked the page.
|
||||
``pages.full_width`` — per-page full-width layout toggle.
|
||||
``pages.font_small`` — per-page compact typography toggle.
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
|
||||
if "full_width" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0")
|
||||
if "font_small" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_global_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)"
|
||||
)
|
||||
|
||||
|
||||
@register(14, "v5.13.0: fix stale LLM provider api_base values")
|
||||
def _migration_fix_llm_api_bases(conn: sqlite3.Connection) -> None:
|
||||
"""Clear `api_base` values that freeze a provider URL to a wrong/default value.
|
||||
|
||||
The Agent "Test connection" uses the per-user (or global) stored `api_base`
|
||||
when present, so an old/incorrect value (e.g. Mistral `…/v2`, Cohere
|
||||
`…/v2`, Google native `/v1beta`) keeps failing even after `PROVIDERS` is
|
||||
corrected. Two kinds of rows are reset to the provider default:
|
||||
|
||||
* known-wrong legacy bases from earlier releases;
|
||||
* a stored base identical to the current provider default (a no-op
|
||||
override that would block future default changes).
|
||||
"""
|
||||
from app.services.llm_client import PROVIDERS
|
||||
|
||||
legacy: dict[str, set[str]] = {
|
||||
"mistral": {"https://api.mistral.ai/v2"},
|
||||
"cohere": {"https://api.cohere.com/v2", "https://api.cohere.com/v1",
|
||||
"https://api.cohere.ai/v2"},
|
||||
"google": {"https://generativelanguage.googleapis.com/v1beta"},
|
||||
"perplexity": {"https://api.perplexity.ai/v1"},
|
||||
"chutes": {"https://api.chutes.ai/v1"},
|
||||
"sensenova": {"https://token.sensenova.cn/v1"},
|
||||
"ltx": {"https://api.ltx.io/v1"},
|
||||
"memtensor": {"https://memos.memtensor.cn/api/openmem/v1"},
|
||||
}
|
||||
for provider, (base, _) in PROVIDERS.items():
|
||||
if base:
|
||||
legacy.setdefault(provider, set()).update({base, base.rstrip("/")})
|
||||
|
||||
for provider, bases in legacy.items():
|
||||
variants = {b for b in bases if b}
|
||||
variants |= {b.rstrip("/") for b in bases if b}
|
||||
for table in ("user_llm_keys", "llm_config"):
|
||||
for value in variants:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?",
|
||||
(provider, value),
|
||||
)
|
||||
|
||||
|
||||
@register(15, "v5.14.0: synced blocks")
|
||||
def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
|
||||
"""v5.14.0 — Synced blocks: a block created once, displayed &
|
||||
edited across multiple pages.
|
||||
|
||||
``synced_blocks`` — source-of-truth content for synced blocks.
|
||||
``page_blocks`` — per-page reference to a synced block
|
||||
(so each page can independently decide to use/unsync).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS synced_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '[]',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_synced_blocks_ws ON synced_blocks(workspace)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_synced_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
synced_block_id INTEGER NOT NULL REFERENCES synced_blocks(id) ON DELETE CASCADE,
|
||||
block_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, synced_block_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_psb_page ON page_synced_blocks(page_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(16, "v6.0.0: offline sync queue")
|
||||
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — PWA offline support.
|
||||
|
||||
``offline_sync_queue`` persists server-side the mutations received from
|
||||
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
|
||||
audited/replayed per device.
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
-- 'page_create', 'page_update', 'page_delete', 'page_move',
|
||||
-- 'collection_create', 'collection_update', 'collection_delete'
|
||||
payload TEXT NOT NULL,
|
||||
client_timestamp REAL NOT NULL,
|
||||
server_version INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- 'pending', 'syncing', 'synced', 'failed'
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
|
||||
)
|
||||
|
||||
|
||||
@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
|
||||
def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Granular permissions (page-level, collection-level,
|
||||
property-level access control + reusable user groups).
|
||||
|
||||
``user_groups`` — named groups scoped to a workspace.
|
||||
``group_members`` — users inside a group (N-ary join).
|
||||
``page_permissions`` — explicit grants for block-editor pages
|
||||
(``pages`` table). user_id XOR group_id.
|
||||
``collection_permissions`` — explicit grants for databases.
|
||||
``property_permissions`` — explicit viewer/editor grants per property.
|
||||
``permission_audit_log`` — immutable trail of every grant/revoke.
|
||||
``pages.permission_type`` / ``collections.permission_type`` — access
|
||||
mode: 'inherit' (default, follows the
|
||||
workspace/collection chain) | 'restricted'
|
||||
| 'private' (explicit grants only).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(page_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS collection_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS property_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | editor
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, property_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS permission_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_type TEXT NOT NULL, -- page | collection | property | group
|
||||
resource_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
|
||||
target_user_id INTEGER,
|
||||
target_group_id INTEGER,
|
||||
old_role TEXT,
|
||||
new_role TEXT,
|
||||
performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
|
||||
"ON permission_audit_log(resource_type, resource_id, created_at)"
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
|
||||
@register(19, "v6.0.0: web clipper — extension devices & clips")
|
||||
def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Web Clipper: extension browser + capture."""
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL DEFAULT 'clipper',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
token_hash TEXT NOT NULL DEFAULT '',
|
||||
scopes TEXT NOT NULL DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_user ON extension_devices(user_id, revoked)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_device ON extension_devices(device_id)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
clip_type TEXT NOT NULL DEFAULT 'article',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
target_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
target_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
title TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_user ON extension_clips(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_device ON extension_clips(device_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(20, "v6.3.0: api v2 — scopes, expires_at, audit, webhooks, idempotency")
|
||||
def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
"""v6.3.0 — API publique complète v2.
|
||||
|
||||
``api_tokens`` — adds ``scopes`` + ``expires_at`` (idempotent ALTER).
|
||||
``webhook_deliveries`` — delivery log for outbound webhooks (CRUD simple phase 1).
|
||||
``api_audit_log`` — immutable audit trail for v2 mutations.
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMP")
|
||||
# Backfill existing tokens without scopes
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL DEFAULT '',
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_user ON api_audit_log(user_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_resource ON api_audit_log(resource_type, resource_id)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
http_code INTEGER,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_wd_webhook ON webhook_deliveries(webhook_id, created_at)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
response_json TEXT NOT NULL DEFAULT '{}',
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
||||
|
||||
|
||||
@register(21, "v6.4.0: webhooks prod — event, retry ledger")
|
||||
def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
"""v6.4.0 — Webhooks v2 production.
|
||||
|
||||
``webhook_deliveries`` gains ``event`` (which event was delivered) and
|
||||
``next_retry_at`` (epoch seconds; picked up by the retry scheduler).
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at REAL")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_wd_retry ON webhook_deliveries(status, next_retry_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
|
||||
Every write on a page / collection increments its ``sync_version`` so a
|
||||
reconnecting client can detect edit-edit conflicts via version mismatch.
|
||||
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
|
||||
write path (no need to patch dozens of ``UPDATE`` call-sites).
|
||||
"""
|
||||
for table in ("pages", "collection_pages", "collections"):
|
||||
_add_sync_version(conn, table)
|
||||
|
||||
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
|
||||
# `recursive_triggers` is OFF by default, so the inner UPDATE never
|
||||
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
|
||||
AFTER UPDATE ON pages
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
|
||||
AFTER UPDATE ON collection_pages
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
|
||||
AFTER UPDATE ON collections
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
|
||||
|
||||
@register(22, "v6.5.0: database row content pages")
|
||||
def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"""v6.5.0 — Synced blocks production: content for database rows.
|
||||
|
||||
A database row (``collection_pages``) gains a shadow ``pages`` row
|
||||
(``pages.collection_row_id``) that carries the Notion-style block
|
||||
content of the row: the full page editor, synced blocks, versions and
|
||||
realtime all work on it unchanged.
|
||||
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
"REFERENCES collection_pages(id) ON DELETE CASCADE"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
+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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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]}
|
||||
+1131
-70
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
"""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
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
except Exception:
|
||||
pass
|
||||
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),
|
||||
)
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
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()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
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"}
|
||||
+817
-51
File diff suppressed because it is too large
Load Diff
+635
-85
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()
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""FlowDeck — unified import API (v5.6.0, Phase 0/1/2).
|
||||
|
||||
Exposes the importer registry, a dry-run preview, a synchronous run and an
|
||||
optional background job with polling. Works with the existing workspace cookie
|
||||
(``flowdeck_workspace``) and session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.importers import (
|
||||
get_job,
|
||||
list_jobs,
|
||||
list_sources,
|
||||
parse_upload,
|
||||
preview_result,
|
||||
resolve_relations,
|
||||
run_import,
|
||||
start_import_job,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
page_router = APIRouter(tags=["import"])
|
||||
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {}
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
def _workspace(request: Request) -> tuple[int | None, str]:
|
||||
"""Resolve (workspace_id, login) from the workspace cookie + session."""
|
||||
ws_id: int | None = None
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
value = int(cookie)
|
||||
if value > 0:
|
||||
ws_id = value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
user = _current_user(request)
|
||||
login = user.get("login", "") if user else ""
|
||||
return ws_id, login
|
||||
|
||||
|
||||
async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
source_id = form.get("source") or None
|
||||
return filename, data, source_id
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def import_preview(request: Request):
|
||||
"""Dry-run: parse the upload and describe what would be created."""
|
||||
filename, data, source_id = await _read_upload(request)
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
out = preview_result(result)
|
||||
out["detected_source"] = imp.source_id
|
||||
out["source_label"] = imp.label
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def import_run(request: Request):
|
||||
"""Import an upload (synchronously, or as a background job when async=true)."""
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
async_mode = str(form.get("async", "false")).lower() in ("true", "1", "yes")
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if async_mode:
|
||||
job = start_import_job(
|
||||
filename=filename, data=data, source_id=source_id,
|
||||
workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
return {"status": "queued", "job_id": job["id"]}
|
||||
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
if imp.source_id == "notion":
|
||||
with get_conn() as conn:
|
||||
report["relations"] = resolve_relations(conn, ws_id)
|
||||
for page_id in report.get("page_ids", [])[:100]:
|
||||
try:
|
||||
await fire_event("page.created", {"page_id": page_id, "title": "", "workspace": login})
|
||||
except Exception: # noqa: BLE001 - events are best-effort
|
||||
pass
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge")
|
||||
async def import_forge(request: Request):
|
||||
"""Import a forge repo's issues (+ labels/milestones) into collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
state = str(body.get("state") or "all")
|
||||
include_labels = bool(body.get("include_labels", True))
|
||||
include_milestones = bool(body.get("include_milestones", True))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
adapter = GiteaForgeAdapter(client)
|
||||
elif provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
adapter = GitHubAdapter(token)
|
||||
else:
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
from app.services.importers.forge import fetch_forge_issues
|
||||
result = await fetch_forge_issues(
|
||||
adapter, owner, repo, provider=provider, state=state,
|
||||
include_labels=include_labels, include_milestones=include_milestones,
|
||||
)
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
)
|
||||
report["detected_source"] = f"forge:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge-repo")
|
||||
async def import_forge_repo(request: Request):
|
||||
"""Import a forge repo's text files as pages (folder hierarchy preserved)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
path = str(body.get("path") or "")
|
||||
max_files = min(int(body.get("max_files") or 200), 1000)
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
adapter = _forge_adapter(request, provider)
|
||||
|
||||
from app.services.importers.forge_repo import fetch_forge_repo
|
||||
result = await fetch_forge_repo(
|
||||
adapter, owner, repo, provider=provider, path=path, max_files=max_files,
|
||||
)
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = f"forge-repo:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/url")
|
||||
async def import_url(request: Request):
|
||||
"""Web clipper: fetch a URL and create a page (bookmark card + content)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = str(body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url is required")
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
from app.services.importers.url_fetch import fetch_url_result
|
||||
try:
|
||||
result = await fetch_url_result(url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
if not result.pages:
|
||||
raise HTTPException(422, "; ".join(result.warnings) or "Page introuvable")
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = "url"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/run-batch")
|
||||
async def import_run_batch(request: Request):
|
||||
"""Import several uploaded files sequentially, returning one report each."""
|
||||
form = await request.form()
|
||||
uploads = form.getlist("file")
|
||||
if not uploads:
|
||||
raise HTTPException(400, "file field required")
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
results: list[dict] = []
|
||||
summary = {"files": 0, "pages_created": 0, "rows_created": 0, "errors": 0}
|
||||
for upload in uploads:
|
||||
filename = (getattr(upload, "filename", "") or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "File too large"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "Format non reconnu"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
results.append({"filename": filename, "report": report})
|
||||
summary["files"] += 1
|
||||
summary["pages_created"] += report.get("pages_created", 0)
|
||||
summary["rows_created"] += report.get("rows_created", 0)
|
||||
summary["errors"] += len(report.get("errors", []))
|
||||
return {"status": "ok", "summary": summary, "results": results}
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
return resolve_relations(conn, ws_id)
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
payload = json.dumps(job.get("report") or {}, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="import-{job_id}.json"'},
|
||||
)
|
||||
|
||||
|
||||
def _forge_adapter(request: Request, provider: str):
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
return GiteaForgeAdapter(client)
|
||||
if provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
return GitHubAdapter(token)
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
|
||||
def _int_or_none(value) -> int | None:
|
||||
try:
|
||||
ivalue = int(value)
|
||||
return ivalue if ivalue > 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mapping(value) -> dict[str, str] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
return {str(k): str(v) for k, v in parsed.items() if v}
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mode(value) -> str | None:
|
||||
mode = str(value or "").strip().lower()
|
||||
return mode if mode in ("skip", "update", "duplicate") else None
|
||||
|
||||
|
||||
def _user_oauth_token(request: Request, provider: str) -> str:
|
||||
user = _current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
return ""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=? "
|
||||
"ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"], provider),
|
||||
).fetchone()
|
||||
return row["access_token"] if row else ""
|
||||
+112
-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,54 @@ 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
|
||||
# (direct shares + group shares via group_members)
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
try:
|
||||
recv_rows = conn.execute(
|
||||
"""SELECT DISTINCT s.page_id FROM page_shares s
|
||||
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
|
||||
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
|
||||
(uid, uid, uid),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
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 +282,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 +344,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 +370,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 +401,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 +483,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 +514,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 +523,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,152 @@
|
||||
"""FlowDeck — Notifications API (v4.9.0 collaboration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(request: Request, limit: int = 50):
|
||||
"""List the current user's notifications, newest first."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT n.*, a.login AS actor_login, a.full_name AS actor_name,
|
||||
a.avatar_url AS actor_avatar, a.avatar_color AS actor_color
|
||||
FROM notifications n
|
||||
LEFT JOIN users a ON n.actor_id = a.id
|
||||
WHERE n.user_id=?
|
||||
ORDER BY n.created_at DESC, n.id DESC LIMIT ?""",
|
||||
(user["id"], limit),
|
||||
).fetchall()
|
||||
unread = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {
|
||||
"notifications": [dict(r) for r in rows],
|
||||
"unread": unread,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
c = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
|
||||
(user["id"],),
|
||||
).fetchone()["c"]
|
||||
return {"unread": c}
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?",
|
||||
(nid, user["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE notifications SET is_read=1 WHERE user_id=?",
|
||||
(user["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_read(request: Request):
|
||||
"""Mark all notifications as read."""
|
||||
return await mark_read(request)
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
return {"prefs": notif.get_user_prefs(user["id"])}
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
prefs[key] = bool(body[key])
|
||||
notif.set_user_prefs(user["id"], prefs)
|
||||
return {"status": "ok", "prefs": prefs}
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
from app.services.recurrence import common_timezones
|
||||
return {"timezone": tz, "zones": common_timezones()}
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
"""Update the current user's IANA timezone (empty string = UTC)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
tz = (body.get("timezone") or "").strip()
|
||||
from app.services.recurrence import is_valid_timezone
|
||||
if tz and not is_valid_timezone(tz):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "timezone": tz}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users WHERE login LIKE ? OR full_name LIKE ?
|
||||
ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""",
|
||||
(like, like, q, q),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color
|
||||
FROM users ORDER BY login LIMIT 20"""
|
||||
).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -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,525 @@
|
||||
"""FlowDeck — v6.0.0 Granular permissions API (page/collection/property ACL).
|
||||
|
||||
Backend for the page-editor "Permissions" panel, database property visibility
|
||||
and user-group management. Grants are stored in ``page_permissions`` /
|
||||
``collection_permissions`` / ``property_permissions``; every mutation is logged
|
||||
into ``permission_audit_log`` for the admin audit view.
|
||||
"""
|
||||
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.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["permissions"], prefix="/api/v2")
|
||||
|
||||
|
||||
PAGE_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
COLLECTION_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
PROPERTY_ROLES = ("viewer", "editor")
|
||||
PERMISSION_TYPES = ("inherit", "restricted", "private")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _pm(request: Request) -> PermissionManager:
|
||||
return PermissionManager(_require_user(request)["id"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
try:
|
||||
return request.client.host if request.client else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _perm_list(conn, table: str, fk: str, resource_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*,
|
||||
u.login AS user_login, u.full_name AS user_name,
|
||||
g.name AS group_name
|
||||
FROM {table} p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN user_groups g ON g.id = p.group_id
|
||||
WHERE p.{fk}=? ORDER BY p.id""",
|
||||
(resource_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d.get("user_id"):
|
||||
d["name"] = d["user_name"] or d["user_login"] or f"User #{d['user_id']}"
|
||||
d["kind"] = "user"
|
||||
else:
|
||||
d["name"] = d["group_name"] or f"Group #{d['group_id']}"
|
||||
d["kind"] = "group"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _grant_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, body: dict, table: str, fk: str,
|
||||
allowed_roles: tuple[str, ...],
|
||||
extra_cols: dict | None = None) -> dict:
|
||||
user_id = body.get("user_id")
|
||||
group_id = body.get("group_id")
|
||||
role = (body.get("role") or "").strip()
|
||||
if role not in allowed_roles:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(allowed_roles)}")
|
||||
if not user_id and not group_id:
|
||||
raise HTTPException(400, "Provide either user_id or group_id")
|
||||
if user_id and not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id must be an integer")
|
||||
if group_id and not isinstance(group_id, int):
|
||||
raise HTTPException(400, "group_id must be an integer")
|
||||
actor = _require_user(request)["id"]
|
||||
with get_conn() as conn:
|
||||
if user_id:
|
||||
exists = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "User not found")
|
||||
if group_id:
|
||||
exists = conn.execute("SELECT id FROM user_groups WHERE id=?", (group_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "Group not found")
|
||||
existing = conn.execute(
|
||||
f"SELECT id, role FROM {table} WHERE {fk}=? AND user_id IS ? AND group_id IS ?",
|
||||
(resource_id, user_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(f"UPDATE {table} SET role=? WHERE id=?",
|
||||
(role, existing["id"]))
|
||||
old_role = existing["role"]
|
||||
perm_id = existing["id"]
|
||||
else:
|
||||
cols = [fk, "user_id", "group_id", "role", "granted_by"]
|
||||
vals: list = [resource_id, user_id, group_id, role, actor]
|
||||
for col, val in (extra_cols or {}).items():
|
||||
cols.append(col)
|
||||
vals.append(val)
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
tuple(vals),
|
||||
)
|
||||
perm_id = cur.lastrowid
|
||||
old_role = None
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "grant",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
old_role=old_role, new_role=role, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": perm_id, "role": role, "user_id": user_id, "group_id": group_id}
|
||||
|
||||
|
||||
def _revoke_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, fk: str, perm_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT user_id, group_id, role FROM {table} WHERE id=? AND {fk}=?",
|
||||
(perm_id, resource_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Permission not found")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (perm_id,))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "revoke",
|
||||
target_user_id=row["user_id"], target_group_id=row["group_id"],
|
||||
old_role=row["role"], new_role=None, ip_address=_client_ip(request))
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
def _set_permission_type(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, body: dict) -> dict:
|
||||
ptype = (body.get("permission_type") or "").strip()
|
||||
if ptype not in PERMISSION_TYPES:
|
||||
raise HTTPException(400, f"permission_type must be one of {', '.join(PERMISSION_TYPES)}")
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE {table} SET permission_type=? WHERE id=?", (ptype, resource_id))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "type_change",
|
||||
new_role=ptype, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "permission_type": ptype}
|
||||
|
||||
|
||||
# ═══════════════ Page permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "page_permissions", "page_id", page_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_page_permission(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
return {
|
||||
"role": pm.get_page_permission(page_id),
|
||||
"can_edit": pm.can_edit_page(page_id),
|
||||
"can_comment": pm.can_comment_page(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
}
|
||||
|
||||
|
||||
def _page_type(page_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
results = []
|
||||
for g in grants:
|
||||
results.append(_grant_common(request, pm, "page", page_id, g,
|
||||
"page_permissions", "page_id", PAGE_ROLES))
|
||||
return {"status": "ok", "granted": results}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "page", page_id, "page_permissions", "page_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permission-type")
|
||||
async def set_page_permission_type(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "page", page_id, "pages", await request.json())
|
||||
|
||||
|
||||
# ═══════════════ Collection permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "collection_permissions", "collection_id", collection_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_collection_permission(collection_id),
|
||||
"permission_type": _collection_type(collection_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _collection_type(collection_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "collection", collection_id,
|
||||
"collection_permissions", "collection_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permission-type")
|
||||
async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "collection", collection_id,
|
||||
"collections", await request.json())
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
with get_conn() as conn:
|
||||
all_ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()]
|
||||
return {
|
||||
"visible": visible,
|
||||
"hidden": [pid for pid in all_ids if pid not in visible],
|
||||
"can_edit": pm.can_edit_collection(collection_id),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ Property permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "property_permissions", "property_id", property_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine_view": pm.can_view_property(collection_id, property_id),
|
||||
"mine_edit": pm.can_edit_property(collection_id, property_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(property_id, collection_id),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, "Property not found")
|
||||
return _grant_common(request, pm, "property", property_id, body,
|
||||
"property_permissions", "property_id", PROPERTY_ROLES,
|
||||
extra_cols={"collection_id": collection_id})
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
return _revoke_common(request, pm, "property", property_id,
|
||||
"property_permissions", "property_id", perm_id)
|
||||
|
||||
|
||||
# ═══════════════ Groups ═══════════════
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
pm.log_permission_change("group", gid, "group_create",
|
||||
target_group_id=gid, new_role="",
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": gid}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can edit groups")
|
||||
conn.execute(
|
||||
"UPDATE user_groups SET name=?, description=? WHERE id=?",
|
||||
(name, body.get("description") or "", group_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can delete groups")
|
||||
pm.delete_group(group_id)
|
||||
pm.log_permission_change("group", group_id, "group_delete",
|
||||
target_group_id=group_id, ip_address=_client_ip(request))
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.add_user_to_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_add",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.remove_user_from_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_remove",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════════ Users (access pickers) + audit ═══════════════
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
if workspace_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT u.id, u.login, u.full_name, u.email, u.avatar_color
|
||||
FROM users u
|
||||
LEFT JOIN workspace_members wm ON wm.user_id=u.id AND wm.workspace_id=?
|
||||
WHERE u.is_admin=1 OR wm.id IS NOT NULL
|
||||
ORDER BY u.login""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_color FROM users ORDER BY login"
|
||||
).fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if q and q not in (d["login"].lower(), d["full_name"].lower(),
|
||||
d["email"].lower()):
|
||||
continue
|
||||
users.append({"id": d["id"], "login": d["login"], "name": d["full_name"] or d["login"],
|
||||
"email": d["email"], "avatar_color": d["avatar_color"]})
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
is_admin = bool(user.get("is_admin"))
|
||||
limit = max(1, min(int(limit), 500))
|
||||
with get_conn() as conn:
|
||||
if not is_admin:
|
||||
owned = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=?", (uid,)
|
||||
).fetchall()
|
||||
if not owned:
|
||||
raise HTTPException(403, "Only a workspace owner or admin can view the audit log")
|
||||
rows = conn.execute(
|
||||
"""SELECT a.*, u.login AS actor_login
|
||||
FROM permission_audit_log a LEFT JOIN users u ON u.id=a.performed_by
|
||||
ORDER BY a.created_at DESC, a.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
@@ -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()}
|
||||
+49
-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,66 @@ 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:
|
||||
from app.config import settings as _s
|
||||
if not _s.public_api_insecure_ok:
|
||||
raise HTTPException(401, "Default token disabled. Set PUBLIC_API_INSECURE_OK=true in dev or use a real Bearer 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,63 @@
|
||||
"""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, Request, 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.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
"""
|
||||
user = SessionManager.decode_session(
|
||||
request.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
return {"error": "unauthorized"}
|
||||
return manager.stats()
|
||||
|
||||
|
||||
@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"}
|
||||
+187
-21
@@ -6,10 +6,11 @@ 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
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -36,18 +37,23 @@ def _slugify(title: str) -> str:
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
if not target_user_id and not target_group_id and not email:
|
||||
raise HTTPException(400, "Provide user_id, group_id or email to share with.")
|
||||
|
||||
# Bridge share permission (view/comment/edit) → granular role
|
||||
# (viewer/commenter/editor) so page_permissions grants stay in sync.
|
||||
_SHARE_TO_ROLE = {"view": "viewer", "comment": "commenter", "edit": "editor"}
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
@@ -61,24 +67,151 @@ 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"]),
|
||||
)
|
||||
# Verify target group exists if group_id given
|
||||
if target_group_id:
|
||||
gtarget = conn.execute("SELECT id FROM user_groups WHERE id=?", (target_group_id,)).fetchone()
|
||||
if not gtarget:
|
||||
raise HTTPException(404, "Target group not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user, group 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 target_group_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(page_id, target_group_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 AND shared_with_group_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 = ""
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_group_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, target_group_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for DBs where the migration has not run yet
|
||||
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,))
|
||||
# ── Mirror group shares into page_permissions so the ACL used by
|
||||
# PermissionManager (can_view/edit/comment) grants real access to
|
||||
# every group member. Best-effort: never break legacy page_shares.
|
||||
if target_group_id:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, target_group_id, _SHARE_TO_ROLE[permission], user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (page=%s group=%s)", page_id, target_group_id)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_group_id": target_group_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
def _mirror_share_grant(conn, page_id: int, group_id: int, role: str, granted_by: int) -> None:
|
||||
"""Upsert a ``page_permissions`` grant mirroring a group ``page_shares`` row.
|
||||
|
||||
Keeps the granular ACL (used by ``PermissionManager``) in sync with what
|
||||
the share dialog shows, so invited groups get effective view/edit rights.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE page_permissions SET role=?, granted_by=? WHERE id=?",
|
||||
(role, granted_by, existing["id"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO page_permissions (page_id, user_id, group_id, role, granted_by) "
|
||||
"VALUES (?, NULL, ?, ?, ?)",
|
||||
(page_id, group_id, role, granted_by),
|
||||
)
|
||||
|
||||
|
||||
def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
"""Remove the mirrored grant when a group share is updated away or deleted."""
|
||||
conn.execute(
|
||||
"DELETE FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
)
|
||||
|
||||
|
||||
@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."""
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, shared_with_group_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),
|
||||
)
|
||||
# Keep the mirrored ACL grant in sync for group shares.
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, gid,
|
||||
{"view": "viewer", "comment": "commenter", "edit": "editor"}[permission],
|
||||
user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (share=%s)", 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."""
|
||||
@@ -86,13 +219,22 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_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("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_revoke(conn, page_id, gid)
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions revoke failed (share=%s)", share_id)
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
@@ -114,14 +256,25 @@ async def list_shares(page_id: int, request: Request):
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url, g.name AS group_name
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
LEFT JOIN user_groups g ON s.shared_with_group_id = g.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -129,6 +282,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_group_id": r["shared_with_group_id"] if "shared_with_group_id" in r.keys() else None,
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
@@ -136,6 +290,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
"group_name": r["group_name"] if "group_name" in r.keys() else None,
|
||||
"kind": "group" if (("shared_with_group_id" in r.keys() and r["shared_with_group_id"]) or ("group_name" in r.keys() and r["group_name"])) else "user",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
@@ -148,7 +304,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(
|
||||
@@ -173,6 +329,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -199,6 +360,11 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync, Bearer v6.4.0).
|
||||
|
||||
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||
``write`` for batch). The legacy ``flowdeck_session`` cookie is still accepted
|
||||
as a fallback so the installed PWA/service worker keeps syncing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.api_v2_helpers import get_bearer_user, has_scope
|
||||
from app.services.sync_engine import SyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
||||
|
||||
_engine = SyncEngine()
|
||||
|
||||
|
||||
def _user(request: Request, authorization: str | None = None,
|
||||
*, required_scope: str = "read") -> dict:
|
||||
"""Bearer-first auth with session-cookie fallback (offline.js compat)."""
|
||||
auth = authorization or request.headers.get("authorization") or ""
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
try:
|
||||
user = get_bearer_user(request, authorization)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired API token"
|
||||
) from None
|
||||
if not has_scope(user.get("_token_scopes"), required_scope):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Insufficient scope. Required: {required_scope}",
|
||||
)
|
||||
return user
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/delta")
|
||||
async def sync_delta(
|
||||
request: Request,
|
||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||
workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
if workspace_id is None:
|
||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||
if result.get("error") == "forbidden":
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
async def sync_batch(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||
user = _user(request, authorization, required_scope="write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
mutations = body.get("mutations") or []
|
||||
device_id = body.get("device_id") or "unknown"
|
||||
if not isinstance(mutations, list) or not mutations:
|
||||
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
|
||||
|
||||
result = await _engine.apply_batch(user["id"], mutations, device_id)
|
||||
result["server_time"] = SyncEngine._now_epoch()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
|
||||
(user["id"],),
|
||||
).fetchone()["n"]
|
||||
last = conn.execute(
|
||||
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
|
||||
"WHERE user_id=? AND status='synced'",
|
||||
(user["id"],),
|
||||
).fetchone()["last"]
|
||||
return {
|
||||
"pending_count": pending,
|
||||
"last_sync": last,
|
||||
"is_syncing": False,
|
||||
"server_time": SyncEngine._now_epoch(),
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"""FlowDeck — Web Clipper router (v6.0.0).
|
||||
|
||||
Endpoints:
|
||||
GET /api/v2/web-clipper/status
|
||||
POST /api/v2/web-clipper/auth/verify
|
||||
POST /api/v2/web-clipper/clip
|
||||
GET /api/v2/web-clipper/devices
|
||||
DELETE /api/v2/web-clipper/devices/{id}
|
||||
GET /extensions (HTML download page)
|
||||
|
||||
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.web_clipper import (
|
||||
MAX_CLIP_BYTES,
|
||||
_check_rate_limit,
|
||||
create_page_from_clip,
|
||||
list_devices,
|
||||
log_clip,
|
||||
register_device,
|
||||
revoke_device,
|
||||
sanitize_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web-clipper"])
|
||||
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _user_from_request(request: Request) -> dict | None:
|
||||
# 1) session cookie
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user:
|
||||
return user
|
||||
# 2) Authorization Bearer
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash(token)
|
||||
with get_conn() as conn:
|
||||
# api_tokens (Settings → API tokens)
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# extension_devices
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# legacy user_tokens
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
with get_conn() as conn:
|
||||
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
|
||||
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
|
||||
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
|
||||
|
||||
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
if not device_id:
|
||||
raise HTTPException(status_code=400, detail="device_id required")
|
||||
if len(device_id) > 128:
|
||||
raise HTTPException(status_code=400, detail="device_id too long")
|
||||
try:
|
||||
res = register_device(user["id"], device_id, device_name, extension_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
if res["existing"]:
|
||||
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
|
||||
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
if clen:
|
||||
try:
|
||||
if int(clen) > MAX_CLIP_BYTES + 1024:
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
# Rate limit 50/hour per device
|
||||
if not _check_rate_limit(f"{user['id']}:{device_id}"):
|
||||
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
|
||||
|
||||
url = (body.get("url") or body.get("source_url") or "").strip()
|
||||
title = (body.get("title") or "").strip()
|
||||
content = body.get("content") or body.get("html") or ""
|
||||
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
|
||||
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
|
||||
clip_type = "article"
|
||||
|
||||
if not url and not title and not content:
|
||||
raise HTTPException(status_code=400, detail="url, title or content required")
|
||||
|
||||
# Validate url if present
|
||||
if url:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
# allow bare domain? reject javascript:
|
||||
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
# Cap content bytes
|
||||
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
|
||||
|
||||
# Sanitize HTML content if present
|
||||
if content and "<" in content:
|
||||
# sanitize but keep structure for blocks converter
|
||||
content = sanitize_html(content)[: MAX_CLIP_BYTES]
|
||||
|
||||
# Prepare payload for service
|
||||
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
|
||||
if not _img_b64 and body.get("images"):
|
||||
try:
|
||||
_imgs = body.get("images")
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
"content": content,
|
||||
"content_type": clip_type,
|
||||
"selection_html": body.get("selection_html") or body.get("selection") or "",
|
||||
"image_base64": _img_b64,
|
||||
"tags": body.get("tags") or [],
|
||||
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
|
||||
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
|
||||
"metadata": body.get("metadata") or {},
|
||||
}
|
||||
|
||||
try:
|
||||
result = create_page_from_clip(clip_data, user["id"])
|
||||
except Exception as e:
|
||||
logger.exception("clip creation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create page") from None
|
||||
|
||||
# Log clip
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
sidebar = {}
|
||||
# Simple standalone page reusing base.html
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
user = _user_from_request(request)
|
||||
# Count for auth user
|
||||
devices = []
|
||||
clips = 0
|
||||
if user:
|
||||
try:
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
.ext-hero{{text-align:center;padding:28px 0 8px;}}
|
||||
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
|
||||
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
|
||||
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
|
||||
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
|
||||
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
|
||||
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
|
||||
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
|
||||
.ext-card a:hover{{text-decoration:underline;}}
|
||||
.ext-section{{margin:28px 0;}}
|
||||
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
|
||||
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
|
||||
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
|
||||
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
|
||||
.ext-dev-list{{margin-top:12px;}}
|
||||
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
|
||||
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
|
||||
</style>
|
||||
<div class="ext-page">
|
||||
<div class="ext-hero">
|
||||
<h1>🧩 FlowDeck Web Clipper</h1>
|
||||
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
|
||||
</div>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-card">
|
||||
<h3>🟢 Chrome / Edge</h3>
|
||||
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>🟠 Firefox</h3>
|
||||
<p>Firefox 109+ (Manifest V2 compat).</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>⌨️ Sans extension</h3>
|
||||
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
|
||||
<a href="/help">Docs /help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>How it works</h2>
|
||||
<ol class="ext-steps">
|
||||
<li>Install the extension (.zip) → enable in your browser.</li>
|
||||
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
|
||||
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
|
||||
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>Captures on this account</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
|
||||
<div class="ext-dev-list">
|
||||
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
|
||||
</div>
|
||||
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))
|
||||
@@ -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}")
|
||||
|
||||
+40
-16
@@ -7,11 +7,12 @@ 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")
|
||||
@@ -129,6 +130,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@@ -138,11 +143,17 @@ async def update_comment(request: Request, comment_id: int):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id, resolved FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@@ -205,6 +216,10 @@ async def add_favorite(request: Request):
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@@ -231,8 +246,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"}
|
||||
@@ -240,22 +256,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")
|
||||
@@ -294,6 +304,12 @@ 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"}
|
||||
|
||||
|
||||
@@ -447,6 +463,10 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -473,6 +493,10 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@@ -510,13 +534,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
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")
|
||||
raise HTTPException(409, "Page already assigned to this sprint") from None
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||
|
||||
|
||||
@@ -559,7 +583,7 @@ async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
completed += p["velocity_points"]
|
||||
break
|
||||
|
||||
from datetime import date, timedelta
|
||||
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
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"""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)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
|
||||
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,310 @@
|
||||
"""FlowDeck — helpers for API v2 (v6.3.0).
|
||||
|
||||
Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth.
|
||||
No duplication: thin wrappers over existing services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
# SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z
|
||||
try:
|
||||
# try with seconds
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt)
|
||||
# SQLite has no tz => assume UTC
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
except ValueError:
|
||||
continue
|
||||
# fallback: if already ISO with T/Z, return as-is
|
||||
if "T" in value:
|
||||
return value
|
||||
return value
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
d = dict(row)
|
||||
for k in list(d.keys()):
|
||||
if k in iso_fields and d[k]:
|
||||
iso = to_iso8601(str(d[k]))
|
||||
if iso:
|
||||
d[k] = iso
|
||||
# parse *_json columns
|
||||
if k.endswith("_json") and isinstance(d[k], str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]:
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(default_limit)))
|
||||
except ValueError:
|
||||
limit = default_limit
|
||||
try:
|
||||
offset = int(request.query_params.get("offset", "0"))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
def paginate_headers(total: int) -> dict[str, str]:
|
||||
return {"X-Total-Count": str(total)}
|
||||
|
||||
# ── Scopes (hierarchical: read < write < admin) ──────────────────────────
|
||||
|
||||
SCOPE_RANK = {"read": 1, "write": 2, "admin": 3}
|
||||
VALID_SCOPES = set(SCOPE_RANK.keys())
|
||||
|
||||
def normalize_scopes(raw: str | None) -> set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
|
||||
return {p for p in parts if p in VALID_SCOPES}
|
||||
|
||||
def has_scope(token_scopes: str | None, required: str) -> bool:
|
||||
req_rank = SCOPE_RANK.get(required, 99)
|
||||
# token with higher rank satisfies lower requirement
|
||||
# admin => write => read
|
||||
token_set = normalize_scopes(token_scopes)
|
||||
if not token_set:
|
||||
return False
|
||||
# effective rank = max rank among token scopes
|
||||
eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0)
|
||||
return eff >= req_rank
|
||||
|
||||
def validate_scopes_input(scopes_raw: str | None) -> str:
|
||||
if not scopes_raw:
|
||||
return "read"
|
||||
parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()]
|
||||
for p in parts:
|
||||
if p not in VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin")
|
||||
if not parts:
|
||||
return "read"
|
||||
# dedup preserve order
|
||||
seen = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
return ",".join(seen)
|
||||
|
||||
# ── Bearer auth (unified) ─────────────────────────────────────────────────
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def resolve_bearer_token(token: str) -> dict | None:
|
||||
"""Resolve Bearer token to user dict. Returns None if invalid/expired/revoked.
|
||||
Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain).
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
# dev-only fallback
|
||||
if token == "fd-public-key":
|
||||
if not settings.public_api_insecure_ok:
|
||||
return None
|
||||
# return a synthetic admin-like user? Use first admin or id 1
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
return None
|
||||
th = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
# 1) api_tokens
|
||||
row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone()
|
||||
if row:
|
||||
if row["revoked"]:
|
||||
return None
|
||||
exp = row["expires_at"]
|
||||
if exp:
|
||||
try:
|
||||
# compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS"
|
||||
# parse to epoch
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S")
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = row["id"]
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
# touch last_used_at best-effort
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
# 3) legacy user_tokens (plain storage)
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
return None
|
||||
|
||||
def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
# Prefer explicit Authorization header, fallback to lowercase
|
||||
auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer <token>")
|
||||
token = auth[7:].strip()
|
||||
user = resolve_bearer_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API token")
|
||||
return user
|
||||
|
||||
def require_scope(required: str):
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
if not has_scope(scopes, required):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||
return user
|
||||
return _dep
|
||||
|
||||
# ── RFC 7807 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def problem_response(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
title_map = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
409: "Conflict",
|
||||
422: "Unprocessable Entity",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
status = exc.status_code
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
body = {
|
||||
"type": f"https://flowdeck/api/errors/{status}",
|
||||
"title": title_map.get(status, "Error"),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"instance": str(request.url.path),
|
||||
}
|
||||
return JSONResponse(status_code=status, content=body, media_type="application/problem+json")
|
||||
|
||||
# ── Audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None:
|
||||
try:
|
||||
ip = ""
|
||||
if request and request.client:
|
||||
ip = request.client.host or ""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
_v2_rate_store: dict[str, tuple[float, int]] = {}
|
||||
def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool:
|
||||
"""Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token."""
|
||||
key = token_hash or f"ip:{ip}"
|
||||
now = time.time()
|
||||
window = 60.0
|
||||
max_req = settings.api_v2_rate_limit_per_token
|
||||
start, count = _v2_rate_store.get(key, (now, 0))
|
||||
if now - start > window:
|
||||
_v2_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= max_req:
|
||||
return False
|
||||
_v2_rate_store[key] = (start, count + 1)
|
||||
return True
|
||||
|
||||
# ── Idempotency ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_idempotency(request: Request, user_id: int) -> dict | None:
|
||||
key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key")
|
||||
if not key:
|
||||
return None
|
||||
key = key.strip()[:200]
|
||||
if not key:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone()
|
||||
if row:
|
||||
try:
|
||||
data = json.loads(row["response_json"])
|
||||
return {"data": data, "status": row["status_code"], "key": key}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None:
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)",
|
||||
(key.strip()[:200], user_id, json.dumps(data), status_code),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,414 @@
|
||||
"""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"))
|
||||
# v6.4.0: emit automation.fired (goes through fire_event → outbound
|
||||
# webhooks, but NOT back through automations to avoid recursion).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": automation_id,
|
||||
"name": auto["name"],
|
||||
"trigger": trigger_source,
|
||||
"collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"),
|
||||
"detail": detail,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
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
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FlowDeck — Built-in page (block) templates (v5.12.0).
|
||||
|
||||
Global page templates used by the « + New page » picker. Built-ins live here
|
||||
(code, versioned); user templates live in the ``page_global_templates``
|
||||
table. Block shapes match the editor's storage format (see
|
||||
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _b(btype: str, content: str = "", **extra) -> dict:
|
||||
out = {"type": btype, "content": content}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
"empty": {
|
||||
"name": "Empty",
|
||||
"icon": "📄",
|
||||
"description": "A blank page.",
|
||||
"blocks": [_b("paragraph")],
|
||||
},
|
||||
"meeting_notes": {
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Attendees, agenda, notes, action items.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Meeting notes"),
|
||||
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
|
||||
_b("heading_2", "Agenda"),
|
||||
_b("bulleted_list", "Topic 1"),
|
||||
_b("bulleted_list", "Topic 2"),
|
||||
_b("heading_2", "Notes"),
|
||||
_b("paragraph"),
|
||||
_b("heading_2", "Decisions"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Action items"),
|
||||
_b("to_do", "Owner — due date", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"weekly_report": {
|
||||
"name": "Weekly report",
|
||||
"icon": "📊",
|
||||
"description": "Wins, in progress, blockers, next week.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
|
||||
_b("heading_2", "🎉 Wins"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🚧 In progress"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "⛔ Blockers"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🗓️ Next week"),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"todo_list": {
|
||||
"name": "To-do list",
|
||||
"icon": "✅",
|
||||
"description": "A simple checklist.",
|
||||
"blocks": [
|
||||
_b("heading_1", "To-do"),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"project_doc": {
|
||||
"name": "Project doc",
|
||||
"icon": "🚀",
|
||||
"description": "Goals, status, team, links.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Project title"),
|
||||
_b("callout", "One-line description of the project.", icon="💡"),
|
||||
_b("heading_2", "Goals"),
|
||||
_b("numbered_list"),
|
||||
_b("heading_2", "Status"),
|
||||
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
|
||||
_b("heading_2", "Team"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Resources"),
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def template_list() -> list[dict]:
|
||||
"""Public shape of the built-in templates for the picker UI."""
|
||||
return [
|
||||
{"key": key, "name": t["name"], "icon": t["icon"],
|
||||
"description": t["description"], "builtin": True}
|
||||
for key, t in BUILTIN_TEMPLATES.items()
|
||||
]
|
||||
|
||||
|
||||
def blocks_json_for(key: str) -> str | None:
|
||||
t = BUILTIN_TEMPLATES.get(key)
|
||||
return json.dumps(t["blocks"]) if t else None
|
||||
@@ -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,203 @@
|
||||
"""FlowDeck — Database templates (v5.3.0).
|
||||
|
||||
Defines the built-in (seeded) database templates, materializes a template's
|
||||
schema into real ``collection_properties`` rows, and creates a collection from
|
||||
a template. Templates are stored in ``database_templates`` (name, icon,
|
||||
description, schema_json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# ── Built-in templates ──
|
||||
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
|
||||
SEED_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "Project tracker",
|
||||
"icon": "🚀",
|
||||
"description": "Suivi de projets avec statut, priorité et échéances.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Not started", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "orange"},
|
||||
{"name": "Urgent", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "CRM / Contacts",
|
||||
"icon": "👥",
|
||||
"description": "Gestion des contacts et prospects.",
|
||||
"schema": [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Email", "type": "email"},
|
||||
{"name": "Phone", "type": "phone"},
|
||||
{"name": "Company", "type": "text"},
|
||||
{"name": "Stage", "type": "status", "options": [
|
||||
{"name": "Lead", "color": "gray"},
|
||||
{"name": "Prospect", "color": "blue"},
|
||||
{"name": "Customer", "color": "green"},
|
||||
]},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Task list",
|
||||
"icon": "✅",
|
||||
"description": "Liste de tâches simple avec assignation et échéance.",
|
||||
"schema": [
|
||||
{"name": "Task", "type": "title"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To do", "color": "gray"},
|
||||
{"name": "In progress", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Priority", "type": "select", "options": [
|
||||
{"name": "Low", "color": "gray"},
|
||||
{"name": "Medium", "color": "yellow"},
|
||||
{"name": "High", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Assignee", "type": "person"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Content calendar",
|
||||
"icon": "📅",
|
||||
"description": "Planification de contenu et de publications.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "Article", "color": "blue"},
|
||||
{"name": "Video", "color": "orange"},
|
||||
{"name": "Social", "color": "green"},
|
||||
{"name": "Newsletter", "color": "purple"},
|
||||
]},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Draft", "color": "gray"},
|
||||
{"name": "In review", "color": "yellow"},
|
||||
{"name": "Published", "color": "green"},
|
||||
]},
|
||||
{"name": "Publish date", "type": "date"},
|
||||
{"name": "Category", "type": "select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Notes de réunion avec participants, agenda, notes et actions.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Date", "type": "date"},
|
||||
{"name": "Attendees", "type": "person"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "Scheduled", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Agenda", "type": "text"},
|
||||
{"name": "Notes", "type": "text"},
|
||||
{"name": "Action items", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Reading list",
|
||||
"icon": "📚",
|
||||
"description": "Articles, livres et ressources à lire.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Status", "type": "status", "options": [
|
||||
{"name": "To read", "color": "gray"},
|
||||
{"name": "Reading", "color": "blue"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Notes", "type": "text"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def materialize_properties(conn, collection_id: int, schema: list) -> None:
|
||||
"""Insert ``collection_properties`` rows from a template ``schema``.
|
||||
|
||||
The ``title`` property is represented by ``collection_pages.title`` and is
|
||||
not created as a column. Rows are inserted in schema order.
|
||||
"""
|
||||
position = 0
|
||||
for prop in schema:
|
||||
name = (prop.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
prop_type = prop.get("type", "text")
|
||||
if prop_type == "title":
|
||||
continue
|
||||
options = prop.get("options") or []
|
||||
# idempotency guard — skip if a same-named property already exists
|
||||
exists = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, name),
|
||||
).fetchone()
|
||||
if exists:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format, position)
|
||||
VALUES (?, ?, ?, ?, 'number', ?)""",
|
||||
(collection_id, name, prop_type, json.dumps(options), position),
|
||||
)
|
||||
position += 1
|
||||
|
||||
|
||||
def create_from_template(
|
||||
conn,
|
||||
name: str,
|
||||
template: dict,
|
||||
*,
|
||||
parent_page_id: int | None = None,
|
||||
workspace_id: int | None = None,
|
||||
) -> int:
|
||||
"""Create a collection from a template (with properties + default view).
|
||||
|
||||
``template`` may be a DB row (sqlite Row) or a dict; it must expose
|
||||
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
|
||||
"""
|
||||
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
|
||||
description = template.get("description") if isinstance(template, dict) else template["description"]
|
||||
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
|
||||
try:
|
||||
schema = json.loads(schema_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description or "", icon or "📋", json.dumps(schema),
|
||||
parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(collection_id, "Default View", "table", json.dumps({
|
||||
"visible_properties": ["Title"],
|
||||
"sorts": [],
|
||||
"filters": [],
|
||||
})),
|
||||
)
|
||||
return collection_id
|
||||
@@ -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,888 @@
|
||||
"""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+\[[ xX]\]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({
|
||||
"type": "to_do",
|
||||
"content": m2.group(2).strip(),
|
||||
"checked": m2.group(1).lower() == "x",
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*\d+[.)]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^\d+[.)]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
mq = re.match(r"^>\s?(.*)$", stripped)
|
||||
if mq and line == stripped:
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^>\s?(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
para.append(m2.group(1))
|
||||
i += 1
|
||||
blocks.append({"type": "quote", "content": "\n".join(para)})
|
||||
para = []
|
||||
continue
|
||||
para.append(stripped)
|
||||
i += 1
|
||||
flush_para()
|
||||
return blocks
|
||||
|
||||
|
||||
def _page_blocks(page: dict) -> list:
|
||||
"""Blocks used for HTML/PDF rendering regardless of storage format."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "blocks":
|
||||
return payload
|
||||
if kind == "code":
|
||||
text, lang = payload
|
||||
return [{"type": "code", "content": text, "language": lang}] if text else []
|
||||
if kind == "md":
|
||||
return _md_to_blocks(payload)
|
||||
return []
|
||||
|
||||
|
||||
def _page_markdown_source(page: dict) -> str:
|
||||
"""Raw markdown when the page IS markdown-sourced, else empty string."""
|
||||
kind, payload = _page_source(page)
|
||||
if kind == "md":
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def markdown_to_blocks(md: str) -> list:
|
||||
"""Public wrapper around the GFM→blocks parser (used by page import)."""
|
||||
return _md_to_blocks(md)
|
||||
|
||||
|
||||
# ═══════════════ Markdown ═══════════════
|
||||
|
||||
def blocks_to_markdown(blocks: list) -> str:
|
||||
"""Convert a block array to Markdown (server-side, all block types)."""
|
||||
out: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _block_text(b)
|
||||
if t == "heading_1":
|
||||
out.append(f"# {c}")
|
||||
elif t == "heading_2":
|
||||
out.append(f"## {c}")
|
||||
elif t == "heading_3":
|
||||
out.append(f"### {c}")
|
||||
elif t == "heading_4":
|
||||
out.append(f"#### {c}")
|
||||
elif t == "bulleted_list":
|
||||
out.append(f"- {c}")
|
||||
elif t == "numbered_list":
|
||||
out.append(f"1. {c}")
|
||||
elif t == "to_do":
|
||||
out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}")
|
||||
elif t == "quote":
|
||||
out.append(f"> {c}")
|
||||
elif t == "divider":
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
out.append(blocks_to_markdown(b["children"]))
|
||||
elif t == "math":
|
||||
out.append(f"$$\n{c}\n$$")
|
||||
elif t == "table_of_contents":
|
||||
out.append("[TOC]")
|
||||
elif t == "columns":
|
||||
for child in b.get("children") or []:
|
||||
out.append(blocks_to_markdown([child]))
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "video":
|
||||
out.append(f"[Video]({b.get('src') or ''})")
|
||||
elif t == "audio":
|
||||
out.append(f"[Audio]({b.get('src') or ''})")
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = (b.get("title") or "").strip()
|
||||
out.append(f"[{title or url}]({url})" if title else url)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
if b.get("embed_type") in ("pdf", "download", None, ""):
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
else:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
out.append(blocks_to_markdown(resolved))
|
||||
else:
|
||||
out.append(str(resolved))
|
||||
except Exception:
|
||||
out.append(f"[Synced block {synced_id}]")
|
||||
else:
|
||||
out.append(c)
|
||||
return "\n\n".join(filter(None, out))
|
||||
|
||||
|
||||
def _child_pages(page: dict) -> list:
|
||||
"""Immediate non-deleted children of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"ORDER BY COALESCE(sort_order, created_at) ASC, id ASC",
|
||||
(page["id"],),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
|
||||
"""Markdown for a single page, with optional sub-pages appended."""
|
||||
parts = [f"# {_page_title(page)}", ""]
|
||||
md_source = _page_markdown_source(page)
|
||||
if md_source:
|
||||
parts.append(md_source.strip())
|
||||
else:
|
||||
md = blocks_to_markdown(_page_blocks(page))
|
||||
if md:
|
||||
parts.append(md)
|
||||
md = "\n\n".join(filter(None, parts)).rstrip()
|
||||
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_md = page_to_markdown(sub, include_children=True)
|
||||
if sub_md:
|
||||
md += f"\n\n---\n\n{sub_md}"
|
||||
return md
|
||||
|
||||
|
||||
# ═══════════════ HTML ═══════════════
|
||||
|
||||
def blocks_to_html(blocks: list) -> str:
|
||||
"""Convert a block array to a self-contained HTML fragment."""
|
||||
parts: list[str] = []
|
||||
for b in blocks or []:
|
||||
t = b.get("type", "paragraph")
|
||||
c = _text(b.get("content"))
|
||||
if t == "heading_1":
|
||||
parts.append(f'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "numbered_list":
|
||||
parts.append(f"<li>{c}</li>")
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
|
||||
parts.append(
|
||||
f'<div class="todo"><input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{style}">{c}</span></div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
children = blocks_to_html(b.get("children") or [])
|
||||
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
|
||||
elif t == "quote":
|
||||
parts.append(f"<blockquote>{c}</blockquote>")
|
||||
elif t == "divider":
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
toc = [x for x in (blocks or [])
|
||||
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
|
||||
if toc:
|
||||
items = "".join(
|
||||
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
|
||||
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
|
||||
for x in toc
|
||||
)
|
||||
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
|
||||
elif t == "columns":
|
||||
cols = "".join(
|
||||
f'<div class="column">{blocks_to_html([child])}</div>'
|
||||
for child in (b.get("children") or [])
|
||||
)
|
||||
parts.append(f'<div class="columns">{cols}</div>')
|
||||
elif t == "callout":
|
||||
icon = b.get("icon") or "💡"
|
||||
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
|
||||
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
|
||||
elif t == "video":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
|
||||
elif t == "audio":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = _text(b.get("title")) or url
|
||||
desc = _text(b.get("description"))
|
||||
img = b.get("image") or ""
|
||||
site = _text(b.get("site_name")) or ""
|
||||
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
parts.append(
|
||||
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
emb = (b.get("embed_type") or "")
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
parts.append('<div class="embed-note">[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
|
||||
elif emb == "pdf" and url:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
height = int(b["height"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
parts.append(blocks_to_html(resolved))
|
||||
else:
|
||||
parts.append(f"<p>{_text(resolved)}</p>")
|
||||
except Exception:
|
||||
parts.append(f"<p>[Synced block {synced_id}]</p>")
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _standalone_css() -> str:
|
||||
return """
|
||||
:root{color-scheme:light;}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;}
|
||||
.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;}
|
||||
h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;}
|
||||
h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;}
|
||||
h3{font-size:1.35rem;margin:24px 0 8px;}
|
||||
h4{font-size:1.1rem;margin:20px 0 6px;}
|
||||
p{margin:8px 0;}
|
||||
li{margin:4px 0;}
|
||||
ol{list-style:decimal;padding-left:24px;}
|
||||
ul{list-style:disc;padding-left:24px;}
|
||||
blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;}
|
||||
hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;}
|
||||
pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;}
|
||||
code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;}
|
||||
pre code{background:none;padding:0;font-size:13px;}
|
||||
.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;}
|
||||
details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;}
|
||||
details summary{cursor:pointer;font-weight:600;}
|
||||
details[open] summary{margin-bottom:8px;}
|
||||
.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;}
|
||||
.todo input{margin-top:5px;}
|
||||
.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;}
|
||||
.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;}
|
||||
.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;}
|
||||
.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;}
|
||||
.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;}
|
||||
.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;}
|
||||
.callout>span{font-size:20px;flex-shrink:0;}
|
||||
.math{margin:14px 0;overflow-x:auto;}
|
||||
figure{margin:16px 0;text-align:center;}
|
||||
figure img{max-width:100%;border-radius:8px;}
|
||||
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
|
||||
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
|
||||
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
|
||||
.ftable th{background:#f6f8fa;font-weight:600;}
|
||||
.ftable tr:nth-child(even) td{background:#fcfcfd;}
|
||||
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
|
||||
a{color:#0969da;}
|
||||
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
|
||||
"""
|
||||
|
||||
|
||||
def page_to_standalone_html(
|
||||
page: dict,
|
||||
*,
|
||||
include_children: bool = True,
|
||||
base_url: str = "",
|
||||
) -> str:
|
||||
"""Return a standalone, self-contained HTML document for a page."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
|
||||
meta_updated = page.get("updated_at") or ""
|
||||
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
|
||||
|
||||
sub_html = ""
|
||||
if include_children:
|
||||
for sub in _child_pages(page):
|
||||
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
|
||||
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
|
||||
sub_html += "</div>"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{_text(title)}</title>
|
||||
<style>{_standalone_css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
{sub_html}
|
||||
{footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ═══════════════ PDF ═══════════════
|
||||
|
||||
def _pdf_html(page: dict) -> str:
|
||||
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
|
||||
title = _page_title(page)
|
||||
body = blocks_to_html(_page_blocks(page))
|
||||
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
|
||||
<style>
|
||||
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
|
||||
h1{{font-size:26px;margin:0 0 10px;}}
|
||||
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
|
||||
h3{{font-size:16px;margin:18px 0 6px;}}
|
||||
h4{{font-size:14px;margin:14px 0 4px;}}
|
||||
p,li{{margin:4px 0;}}
|
||||
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
|
||||
code{{font-family:monospace;font-size:10px;}}
|
||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||
table{{border-collapse:collapse;width:100%;}}
|
||||
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
|
||||
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
|
||||
.ftable th{{background:#f0f0f0;font-weight:bold;}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
.todo{{margin:4px 0;}}
|
||||
.math{{font-style:italic;margin:10px 0;}}
|
||||
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
|
||||
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
|
||||
</style></head><body>
|
||||
<h1>{_text(title)}</h1>
|
||||
{body}
|
||||
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def page_to_pdf_bytes(page: dict) -> bytes:
|
||||
"""Render a page to a PDF.
|
||||
|
||||
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
|
||||
(needs system libs: pango + fonts; available in the Docker image).
|
||||
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
|
||||
absent (e.g. a Windows dev host) — text/table content still exports,
|
||||
though emoji are limited to monochrome by the engine.
|
||||
"""
|
||||
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html = page_to_standalone_html(page, include_children=False)
|
||||
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
|
||||
except Exception: # ImportError or missing native libs (OSError) -> fallback
|
||||
pass
|
||||
# 2) xhtml2pdf fallback (pure Python)
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
src = _pdf_html(page)
|
||||
buf = io.BytesIO()
|
||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||
if pdf.err:
|
||||
raise RuntimeError(f"PDF generation failed: {pdf.err}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ═══════════════ Static site (zip) ═══════════════
|
||||
|
||||
def _site_index_html(pages: list[dict]) -> str:
|
||||
"""Build the index.html of the static site (list of all pages)."""
|
||||
def link(p: dict) -> str:
|
||||
title = _page_title(p)
|
||||
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
|
||||
|
||||
items = "".join(link(p) for p in pages)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<title>FlowDeck Site</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
|
||||
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
|
||||
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
|
||||
|
||||
|
||||
def build_static_site_bytes(root_page: dict) -> bytes:
|
||||
"""Build a full static site as a zip: index.html + one HTML file per page."""
|
||||
pages = [root_page] + _child_pages(root_page)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("index.html", _site_index_html(pages))
|
||||
for p in pages:
|
||||
title = _page_title(p)
|
||||
name = f"{quote(title, safe='')}.html"
|
||||
z.writestr(name, page_to_standalone_html(p, include_children=False))
|
||||
return buf.getvalue()
|
||||
@@ -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,118 @@ 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()
|
||||
|
||||
# ── issues / labels / milestones ──
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
"""List issues (pull requests are filtered out)."""
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100, "page": page},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
batch = resp.json()
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(i for i in batch if "pull_request" not in i)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||
headers=self._headers,
|
||||
params={"per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Flatten the repo tree into file entries (``path``, ``size``)."""
|
||||
tree = await self.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/")
|
||||
files = [
|
||||
{"path": item["path"], "size": item.get("size", 0)}
|
||||
for item in tree
|
||||
if item.get("type") == "blob" and item.get("path")
|
||||
]
|
||||
if prefix:
|
||||
files = [f for f in files if f["path"].startswith(prefix + "/") or f["path"] == prefix]
|
||||
return files
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
"""Check whether the access token is still valid."""
|
||||
async with 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,58 @@
|
||||
"""FlowDeck — importers package (v5.6.0).
|
||||
|
||||
Importing this package registers every built-in importer. Use
|
||||
:func:`parse_upload` to detect a source and :func:`run_import` to persist it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.importers import ( # noqa: F401 - registration side effects
|
||||
bookmarks,
|
||||
calendar,
|
||||
docx,
|
||||
html_notes,
|
||||
markdown,
|
||||
notion,
|
||||
obsidian,
|
||||
opml,
|
||||
outline,
|
||||
pdf,
|
||||
standard_notes,
|
||||
tabular,
|
||||
)
|
||||
from app.services.importers.base import ( # noqa: F401
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
all_importers,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
list_sources,
|
||||
)
|
||||
from app.services.importers.jobs import ( # noqa: F401
|
||||
create_job,
|
||||
get_job,
|
||||
list_jobs,
|
||||
parse_upload,
|
||||
start_import_job,
|
||||
)
|
||||
from app.services.importers.pipeline import preview_result, resolve_relations, run_import # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"ImportAttachment",
|
||||
"ImportPage",
|
||||
"ImportResult",
|
||||
"Importer",
|
||||
"all_importers",
|
||||
"detect_importer",
|
||||
"get_importer",
|
||||
"list_sources",
|
||||
"create_job",
|
||||
"get_job",
|
||||
"list_jobs",
|
||||
"parse_upload",
|
||||
"start_import_job",
|
||||
"preview_result",
|
||||
"run_import",
|
||||
"resolve_relations",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try: # PyYAML ships transitively via uvicorn[standard]
|
||||
import yaml
|
||||
except Exception: # pragma: no cover - fallback parser below
|
||||
yaml = None
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
raw = m.group(1)
|
||||
body = text[m.end():]
|
||||
if yaml is not None:
|
||||
try:
|
||||
meta = yaml.safe_load(raw)
|
||||
if isinstance(meta, dict):
|
||||
return meta, body
|
||||
except Exception: # noqa: BLE001 - fall back to the simple parser
|
||||
pass
|
||||
return _simple_yaml(raw), body
|
||||
|
||||
|
||||
def _simple_yaml(raw: str) -> dict[str, Any]:
|
||||
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
|
||||
meta: dict[str, Any] = {}
|
||||
current: str | None = None
|
||||
for line in raw.splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if line.lstrip().startswith("- ") and current:
|
||||
meta.setdefault(current, [])
|
||||
if isinstance(meta[current], list):
|
||||
meta[current].append(_scalar(line.lstrip()[2:].strip()))
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current = key
|
||||
if not value:
|
||||
meta[key] = []
|
||||
elif value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
|
||||
else:
|
||||
meta[key] = _scalar(value)
|
||||
return meta
|
||||
|
||||
|
||||
def _scalar(value: str) -> Any:
|
||||
v = value.strip().strip('"').strip("'")
|
||||
if v.lower() in ("true", "false"):
|
||||
return v.lower() == "true"
|
||||
if re.fullmatch(r"-?\d+", v):
|
||||
return int(v)
|
||||
if re.fullmatch(r"-?\d+\.\d+", v):
|
||||
return float(v)
|
||||
return v
|
||||
|
||||
|
||||
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
|
||||
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
|
||||
into Markdown images so the block converter can render them."""
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
|
||||
label = (alias or target).strip()
|
||||
if bang == "!":
|
||||
return f"" if embeds else label
|
||||
return f"[{label}]({target})"
|
||||
|
||||
return _WIKILINK_RE.sub(repl, text)
|
||||
|
||||
|
||||
def normalize_title(value: Any) -> str:
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def coerce_tags(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r"[,\s]+", value)
|
||||
return [p.strip().lstrip("#") for p in parts if p.strip()]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"[*_~#>]+", "", text)
|
||||
return text.strip()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""FlowDeck — unified import framework (v5.6.0, Phase 0).
|
||||
|
||||
Defines the normalized data model shared by every importer and the registry
|
||||
used to auto-detect a source. An :class:`Importer` turns an uploaded file into
|
||||
an :class:`ImportResult` (pages, attachments, warnings, stats) which the
|
||||
pipeline (:mod:`app.services.importers.pipeline`) persists into FlowDeck.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str:
|
||||
"""Best-effort decode of uploaded bytes (BOM aware, latin-1 fallback)."""
|
||||
for enc in ("utf-8-sig", "utf-8", "utf-16", "latin-1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportAttachment:
|
||||
"""A binary asset extracted from an archive/vault."""
|
||||
|
||||
source_path: str
|
||||
filename: str
|
||||
data: bytes = b""
|
||||
mime: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportPage:
|
||||
"""One page to create. ``markdown`` is converted to blocks by the pipeline
|
||||
unless ``blocks`` is already provided. ``collection`` marks a database
|
||||
(Notion database, Excel sheet…) whose rows become ``collection_pages``."""
|
||||
|
||||
title: str = "Untitled"
|
||||
markdown: str = ""
|
||||
blocks: list[dict] = field(default_factory=list)
|
||||
source_path: str = ""
|
||||
parent_path: str = ""
|
||||
properties: dict[str, Any] = field(default_factory=dict)
|
||||
collection: dict[str, Any] | None = None
|
||||
external_id: str = ""
|
||||
page_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
"""Normalized output of any importer."""
|
||||
|
||||
source: str = ""
|
||||
pages: list[ImportPage] = field(default_factory=list)
|
||||
attachments: list[ImportAttachment] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
if message and message not in self.warnings:
|
||||
self.warnings.append(message)
|
||||
|
||||
def finalize(self) -> ImportResult:
|
||||
self.stats.setdefault("pages", len(self.pages))
|
||||
self.stats.setdefault("collections", sum(1 for p in self.pages if p.collection))
|
||||
self.stats.setdefault("attachments", len(self.attachments))
|
||||
self.stats.setdefault("warnings", len(self.warnings))
|
||||
return self
|
||||
|
||||
|
||||
class Importer(ABC):
|
||||
"""Base class for a source importer."""
|
||||
|
||||
source_id: str = ""
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
extensions: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
"""Return True when this importer recognizes the uploaded file."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
"""Parse the upload into a normalized :class:`ImportResult`."""
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"extensions": list(self.extensions),
|
||||
}
|
||||
|
||||
|
||||
REGISTRY: list[Importer] = []
|
||||
|
||||
|
||||
def register_importer(cls: type[Importer]) -> type[Importer]:
|
||||
"""Class decorator registering an importer instance."""
|
||||
REGISTRY.append(cls())
|
||||
REGISTRY.sort(key=lambda i: i.order)
|
||||
return cls
|
||||
|
||||
|
||||
def all_importers() -> list[Importer]:
|
||||
return list(REGISTRY)
|
||||
|
||||
|
||||
def get_importer(source_id: str) -> Importer | None:
|
||||
for imp in REGISTRY:
|
||||
if imp.source_id == source_id:
|
||||
return imp
|
||||
return None
|
||||
|
||||
|
||||
def detect_importer(filename: str, data: bytes) -> Importer | None:
|
||||
"""First importer that recognizes the file, else None."""
|
||||
for imp in REGISTRY:
|
||||
try:
|
||||
if imp.detect(filename, data):
|
||||
return imp
|
||||
except Exception: # noqa: BLE001 - a broken detector must not break detection
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def list_sources() -> list[dict[str, Any]]:
|
||||
return [imp.info() for imp in REGISTRY]
|
||||
|
||||
|
||||
def make_collection(name: str, schema: list[dict], rows: list[dict],
|
||||
*, source_path: str = "", external_id: str = "") -> ImportPage:
|
||||
"""Build an ImportPage carrying a collection spec (database import).
|
||||
|
||||
``rows`` entries are ``{"title": str, "properties": {name: value}}``.
|
||||
"""
|
||||
return ImportPage(
|
||||
title=name or "Imported database",
|
||||
collection={"name": name or "Imported database", "schema": schema, "rows": rows},
|
||||
source_path=source_path or name,
|
||||
external_id=external_id or source_path or name,
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
|
||||
|
||||
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
|
||||
normalized into a FlowDeck collection (URL, description, tags, created date).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers._common import coerce_tags
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Created", "type": "date"},
|
||||
]
|
||||
|
||||
_TAG_RE = re.compile(
|
||||
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
|
||||
|
||||
|
||||
def _strip_tags(value: str) -> str:
|
||||
return re.sub(r"<[^>]+>", "", value or "").strip()
|
||||
|
||||
|
||||
def _iso_from_epoch(value: Any) -> str:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
|
||||
except (ValueError, TypeError, OSError, OverflowError):
|
||||
return ""
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if re.fullmatch(r"\d{10}", text):
|
||||
return _iso_from_epoch(text)
|
||||
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
|
||||
|
||||
|
||||
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if url:
|
||||
props["URL"] = url
|
||||
if description:
|
||||
props["Description"] = description
|
||||
tag_list = coerce_tags(tags)
|
||||
if tag_list:
|
||||
props["Tags"] = tag_list
|
||||
if created:
|
||||
props["Created"] = created
|
||||
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
|
||||
|
||||
|
||||
def parse_netscape(html: str) -> list[dict]:
|
||||
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
|
||||
rows: list[dict] = []
|
||||
folder = ""
|
||||
for match in _TAG_RE.finditer(html):
|
||||
if match.group("folder") is not None:
|
||||
folder = _strip_tags(match.group("folder"))
|
||||
continue
|
||||
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
|
||||
url = attrs.get("href") or attrs.get("HREF") or ""
|
||||
if not url:
|
||||
continue
|
||||
title = _strip_tags(match.group("title"))
|
||||
tags = attrs.get("tags") or attrs.get("TAGS") or folder
|
||||
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
|
||||
return rows
|
||||
|
||||
|
||||
def _csv_rows(text: str) -> list[dict]:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
|
||||
|
||||
|
||||
class _BookmarkBase(Importer):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets"
|
||||
description = ""
|
||||
order = 44
|
||||
keywords: tuple[str, ...] = ()
|
||||
|
||||
def _hint(self, filename: str, text: str) -> bool:
|
||||
low = filename.lower()
|
||||
return any(k in low or k in text.lower() for k in self.keywords)
|
||||
|
||||
|
||||
@register_importer
|
||||
class RaindropImporter(_BookmarkBase):
|
||||
source_id = "raindrop"
|
||||
label = "Raindrop.io"
|
||||
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 46
|
||||
keywords = ("raindrop",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
r.get("note") or r.get("excerpt") or "",
|
||||
r.get("tags", ""), _iso(r.get("created", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class PocketImporter(_BookmarkBase):
|
||||
source_id = "pocket"
|
||||
label = "Pocket"
|
||||
description = "Export Pocket (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 45
|
||||
keywords = ("pocket",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
"", r.get("tags", ""), _iso(r.get("time_added", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ReadwiseImporter(_BookmarkBase):
|
||||
source_id = "readwise"
|
||||
label = "Readwise"
|
||||
description = "Export Readwise (highlights CSV) → collection de surlignages."
|
||||
extensions = (".csv", ".md", ".markdown")
|
||||
order = 47
|
||||
keywords = ("readwise",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
if filename.lower().endswith(".csv"):
|
||||
header = text.splitlines()[0].lower() if text.strip() else ""
|
||||
return "highlight" in header or "book title" in header
|
||||
return True
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
schema = [
|
||||
{"name": "Highlight", "type": "title"},
|
||||
{"name": "Book", "type": "text"},
|
||||
{"name": "Author", "type": "text"},
|
||||
{"name": "Note", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Highlighted at", "type": "date"},
|
||||
]
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
props: dict[str, Any] = {}
|
||||
if r.get("book title"):
|
||||
props["Book"] = r["book title"]
|
||||
if r.get("book author"):
|
||||
props["Author"] = r["book author"]
|
||||
if r.get("note"):
|
||||
props["Note"] = r["note"]
|
||||
tags = coerce_tags(r.get("document tags") or r.get("tags"))
|
||||
if tags:
|
||||
props["Tags"] = tags
|
||||
created = _iso(r.get("highlighted at", ""))
|
||||
if created:
|
||||
props["Highlighted at"] = created
|
||||
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
|
||||
else:
|
||||
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
|
||||
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ShaarliImporter(_BookmarkBase):
|
||||
source_id = "shaarli"
|
||||
label = "Shaarli"
|
||||
description = "Export Shaarli (JSON) → collection de signets."
|
||||
extensions = (".json",)
|
||||
order = 48
|
||||
keywords = ("shaarli",)
|
||||
|
||||
def _records(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
|
||||
obj = obj["links"]
|
||||
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
|
||||
return obj
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
return self._records(data) is not None
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
records = self._records(data) or []
|
||||
rows = [
|
||||
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
|
||||
r.get("tags", ""), _iso(r.get("created", "")))
|
||||
for r in records
|
||||
]
|
||||
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class BookmarksImporter(_BookmarkBase):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets HTML (navigateur)"
|
||||
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
|
||||
extensions = (".html", ".htm")
|
||||
order = 49
|
||||
keywords = ()
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith((".html", ".htm")):
|
||||
return False
|
||||
text = decode_text(data)[:4000].lower()
|
||||
return "netscape-bookmark-file" in text or "<dt><a href" in text
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows = parse_netscape(decode_text(data))
|
||||
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""FlowDeck — iCalendar (.ics) importer (v5.6.0, Phase 4).
|
||||
|
||||
Parses VEVENT blocks (RFC 5545, best-effort) into a FlowDeck calendar
|
||||
collection (start/end, all-day, location, description).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Start", "type": "date"},
|
||||
{"name": "End", "type": "date"},
|
||||
{"name": "All day", "type": "checkbox"},
|
||||
{"name": "Location", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Calendar", "type": "text"},
|
||||
]
|
||||
_UNESCAPE = [("\\n", "\n"), ("\\N", "\n"), ("\\,", ","), ("\\;", ";"), ("\\\\", "\\")]
|
||||
|
||||
|
||||
def _unfold(text: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
if raw[:1] in (" ", "\t") and lines:
|
||||
lines[-1] += raw[1:]
|
||||
else:
|
||||
lines.append(raw)
|
||||
return lines
|
||||
|
||||
|
||||
def _unescape(value: str) -> str:
|
||||
for src, dst in _UNESCAPE:
|
||||
value = value.replace(src, dst)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _parse_prop(line: str) -> tuple[str, dict[str, str], str]:
|
||||
if ":" not in line:
|
||||
return "", {}, ""
|
||||
head, _, value = line.partition(":")
|
||||
parts = head.split(";")
|
||||
name = parts[0].upper()
|
||||
params: dict[str, str] = {}
|
||||
for p in parts[1:]:
|
||||
if "=" in p:
|
||||
k, _, v = p.partition("=")
|
||||
params[k.upper()] = v
|
||||
return name, params, value
|
||||
|
||||
|
||||
def _iso_datetime(value: str, params: dict[str, str]) -> tuple[str, bool]:
|
||||
"""Return (iso, is_all_day)."""
|
||||
v = value.strip()
|
||||
if params.get("VALUE") == "DATE" or re.fullmatch(r"\d{8}", v):
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", v)
|
||||
return (f"{m.group(1)}-{m.group(2)}-{m.group(3)}", True) if m else ("", True)
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", v)
|
||||
if not m:
|
||||
return v, False
|
||||
date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}"
|
||||
return (date + "+00:00" if m.group(7) else date), False
|
||||
|
||||
|
||||
@register_importer
|
||||
class IcsImporter(Importer):
|
||||
source_id = "ics"
|
||||
label = "Calendrier (.ics)"
|
||||
description = "Export iCalendar (Google/Outlook/Apple) → collection d'événements."
|
||||
extensions = (".ics", ".ical")
|
||||
order = 33
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith((".ics", ".ical")):
|
||||
return True
|
||||
return "BEGIN:VCALENDAR" in decode_text(data)[:2000]
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
lines = _unfold(decode_text(data))
|
||||
calendar = ""
|
||||
rows: list[dict] = []
|
||||
event: dict[str, Any] | None = None
|
||||
for line in lines:
|
||||
upper = line.strip().upper()
|
||||
if upper == "BEGIN:VEVENT":
|
||||
event = {}
|
||||
continue
|
||||
if upper == "END:VEVENT":
|
||||
if event is not None:
|
||||
rows.append(_event_row(event, calendar))
|
||||
event = None
|
||||
continue
|
||||
name, params, value = _parse_prop(line.strip())
|
||||
if name == "X-WR-CALNAME" and not event:
|
||||
calendar = _unescape(value)
|
||||
if event is None:
|
||||
continue
|
||||
if name == "SUMMARY":
|
||||
event["title"] = _unescape(value)
|
||||
elif name == "DTSTART":
|
||||
event["start"], event["all_day"] = _iso_datetime(value, params)
|
||||
elif name == "DTEND":
|
||||
event["end"], _ = _iso_datetime(value, params)
|
||||
elif name == "LOCATION":
|
||||
event["location"] = _unescape(value)
|
||||
elif name == "DESCRIPTION":
|
||||
event["description"] = _unescape(value)
|
||||
elif name == "UID":
|
||||
event["uid"] = value
|
||||
result.pages.append(make_collection("Calendar", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _event_row(event: dict[str, Any], calendar: str) -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if event.get("start"):
|
||||
props["Start"] = event["start"]
|
||||
if event.get("end"):
|
||||
props["End"] = event["end"]
|
||||
props["All day"] = bool(event.get("all_day"))
|
||||
if event.get("location"):
|
||||
props["Location"] = event["location"]
|
||||
if event.get("description"):
|
||||
props["Description"] = event["description"]
|
||||
if calendar:
|
||||
props["Calendar"] = calendar
|
||||
return {"title": (event.get("title") or "Event").strip()[:200], "properties": props}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Word (.docx) importer (v5.6.0, Phase 3).
|
||||
|
||||
Converts a Word document (including Google Docs Takeout ``.docx`` exports) into
|
||||
a FlowDeck page: headings, lists, tables and inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HEADING_STYLES = {
|
||||
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
|
||||
"heading 4": 4, "heading 5": 4, "heading 6": 4,
|
||||
}
|
||||
_MIME = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".emf": "image/emf", ".wmf": "image/wmf", ".tiff": "image/tiff",
|
||||
}
|
||||
|
||||
|
||||
def _escape_cell(text: str) -> str:
|
||||
return text.strip().replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def _table_markdown(table) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
rows.append([_escape_cell(cell.text) for cell in row.cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
@register_importer
|
||||
class DocxImporter(Importer):
|
||||
source_id = "docx"
|
||||
label = "Word / Google Docs (.docx)"
|
||||
description = "Document Word : titres, listes, tableaux et images."
|
||||
extensions = (".docx", ".docm")
|
||||
order = 36
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith((".docx", ".docm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
except ImportError:
|
||||
result.warn("python-docx n'est pas installé : import Word indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
doc = Document(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Document illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
lines: list[str] = []
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
style = (para.style.name or "").lower() if para.style else ""
|
||||
images = self._paragraph_images(doc, para, qn, result)
|
||||
if text:
|
||||
level = _HEADING_STYLES.get(style)
|
||||
if level:
|
||||
lines.append("#" * level + " " + text)
|
||||
elif "list bullet" in style or "list paragraph" in style:
|
||||
lines.append("- " + text)
|
||||
elif "list number" in style:
|
||||
lines.append("1. " + text)
|
||||
elif style == "quote":
|
||||
lines.append("> " + text)
|
||||
else:
|
||||
lines.append(text)
|
||||
lines.extend(images)
|
||||
for table in doc.tables:
|
||||
md = _table_markdown(table)
|
||||
if md:
|
||||
lines.append(md)
|
||||
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(lines)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(_page(title, markdown, filename))
|
||||
return result.finalize()
|
||||
|
||||
def _paragraph_images(self, doc, para, qn, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
for blip in para._p.iter(qn("a:blip")):
|
||||
rid = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
|
||||
if not rid:
|
||||
continue
|
||||
part = doc.part.related_parts.get(rid)
|
||||
if part is None or not hasattr(part, "blob"):
|
||||
continue
|
||||
name = Path(str(part.partname)).name or f"image_{len(result.attachments)}.png"
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name, filename=name, data=part.blob,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
|
||||
|
||||
def _page(title: str, markdown: str, filename: str):
|
||||
from app.services.importers.base import ImportPage
|
||||
|
||||
return ImportPage(title=title, markdown=markdown, source_path=filename, external_id=filename)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""FlowDeck — forge (Gitea/GitHub) issues importer (v5.6.0, Phase 4).
|
||||
|
||||
Pulls a repository's issues, labels and milestones through a forge adapter and
|
||||
normalizes them into FlowDeck collections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import ImportResult, make_collection
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> list[str]:
|
||||
labels = issue.get("labels") or []
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
if isinstance(label, dict):
|
||||
name = label.get("name") or label.get("title")
|
||||
else:
|
||||
name = str(label)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_name(issue: dict) -> str:
|
||||
milestone = issue.get("milestone")
|
||||
if isinstance(milestone, dict):
|
||||
return str(milestone.get("title") or milestone.get("name") or "")
|
||||
return str(milestone or "")
|
||||
|
||||
|
||||
def _assignees(issue: dict) -> list[str]:
|
||||
out: list[str] = []
|
||||
for key in ("assignees", "assignee"):
|
||||
value = issue.get(key)
|
||||
if isinstance(value, list):
|
||||
for a in value:
|
||||
login = a.get("login") if isinstance(a, dict) else str(a)
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, dict):
|
||||
login = value.get("login")
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, str) and value and value not in out:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
_ISSUE_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Number", "type": "number"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Labels", "type": "multi_select"},
|
||||
{"name": "Milestone", "type": "text"},
|
||||
{"name": "Assignee", "type": "text"},
|
||||
{"name": "Created", "type": "date"},
|
||||
{"name": "Updated", "type": "date"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Body", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
def build_issues_result(
|
||||
issues: list[dict],
|
||||
*,
|
||||
labels: list[dict] | None = None,
|
||||
milestones: list[dict] | None = None,
|
||||
owner: str = "",
|
||||
repo: str = "",
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Normalize forge issues/labels/milestones into an ImportResult."""
|
||||
result = ImportResult(source=f"forge:{provider}" if provider else "forge")
|
||||
name = f"{owner}/{repo} issues".strip("/ ") or "Issues"
|
||||
rows: list[dict] = []
|
||||
for issue in issues:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if issue.get("number") is not None:
|
||||
props["Number"] = issue["number"]
|
||||
if issue.get("state"):
|
||||
props["State"] = issue["state"]
|
||||
label_names = _label_names(issue)
|
||||
if label_names:
|
||||
props["Labels"] = label_names
|
||||
milestone = _milestone_name(issue)
|
||||
if milestone:
|
||||
props["Milestone"] = milestone
|
||||
assignees = _assignees(issue)
|
||||
if assignees:
|
||||
props["Assignee"] = ", ".join(assignees)
|
||||
if issue.get("created_at"):
|
||||
props["Created"] = issue["created_at"]
|
||||
if issue.get("updated_at"):
|
||||
props["Updated"] = issue["updated_at"]
|
||||
if issue.get("html_url"):
|
||||
props["URL"] = issue["html_url"]
|
||||
if issue.get("body"):
|
||||
props["Body"] = issue["body"]
|
||||
title = issue.get("title") or f"#{issue.get('number', '')}".strip()
|
||||
rows.append({"title": title[:200], "properties": props})
|
||||
|
||||
result.pages.append(make_collection(
|
||||
name, _ISSUE_SCHEMA, rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:issues",
|
||||
external_id=f"{provider}:{owner}/{repo}:issues",
|
||||
))
|
||||
result.stats["rows"] = len(rows)
|
||||
|
||||
if labels:
|
||||
label_schema = [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Color", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
label_rows = [{
|
||||
"title": (lbl.get("name") or lbl.get("title") or "Label")[:200],
|
||||
"properties": {
|
||||
k: v for k, v in (
|
||||
("Color", lbl.get("color")),
|
||||
("Description", lbl.get("description")),
|
||||
) if v
|
||||
},
|
||||
} for lbl in labels]
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} labels".strip("/ "), label_schema, label_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:labels",
|
||||
external_id=f"{provider}:{owner}/{repo}:labels",
|
||||
))
|
||||
|
||||
if milestones:
|
||||
ms_schema = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
ms_rows = []
|
||||
for ms in milestones:
|
||||
props: dict[str, Any] = {}
|
||||
if ms.get("state"):
|
||||
props["State"] = ms["state"]
|
||||
if ms.get("due_on"):
|
||||
props["Due date"] = ms["due_on"]
|
||||
if ms.get("description"):
|
||||
props["Description"] = ms["description"]
|
||||
ms_rows.append({"title": (ms.get("title") or "Milestone")[:200], "properties": props})
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} milestones".strip("/ "), ms_schema, ms_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:milestones",
|
||||
external_id=f"{provider}:{owner}/{repo}:milestones",
|
||||
))
|
||||
|
||||
return result.finalize()
|
||||
|
||||
|
||||
class GiteaForgeAdapter:
|
||||
"""Adapts a :class:`GiteaClient` to the ``list_*`` interface used here."""
|
||||
|
||||
def __init__(self, client) -> None:
|
||||
self._client = client
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
batch = await self._client.get_issues(owner, repo, state=state, page=page, limit=50)
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
return await self._client.get_labels(owner, repo)
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
return await self._client.get_milestones(owner, repo, state=state)
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Recursively flatten Gitea repo contents into file entries."""
|
||||
files: list[dict] = []
|
||||
pending = [path.strip("/")]
|
||||
while pending and len(files) < 5000:
|
||||
current = pending.pop()
|
||||
items = await self._client.get_repo_contents(owner, repo, current)
|
||||
for item in items:
|
||||
if item.get("type") == "dir":
|
||||
pending.append(item.get("path") or item.get("name"))
|
||||
elif item.get("type") == "file":
|
||||
files.append({"path": item.get("path") or item.get("name"), "size": item.get("size", 0)})
|
||||
return files
|
||||
|
||||
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||
return await self._client.get_file_content(owner, repo, path)
|
||||
|
||||
|
||||
async def fetch_forge_issues(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
state: str = "all",
|
||||
include_labels: bool = True,
|
||||
include_milestones: bool = True,
|
||||
) -> ImportResult:
|
||||
"""Fetch issues (and optionally labels/milestones) then normalize them."""
|
||||
issues = await adapter.list_issues(owner, repo, state)
|
||||
labels = None
|
||||
milestones = None
|
||||
if include_labels:
|
||||
try:
|
||||
labels = await adapter.list_labels(owner, repo)
|
||||
except Exception: # noqa: BLE001 - labels are optional
|
||||
labels = None
|
||||
if include_milestones:
|
||||
try:
|
||||
milestones = await adapter.list_milestones(owner, repo, state)
|
||||
except Exception: # noqa: BLE001
|
||||
milestones = None
|
||||
return build_issues_result(
|
||||
issues, labels=labels, milestones=milestones,
|
||||
owner=owner, repo=repo, provider=provider,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — forge repository file importer (v5.6.0, Phase 5).
|
||||
|
||||
Imports a Gitea/GitHub repository's text files as pages, preserving the folder
|
||||
hierarchy. Markdown files become pages; other text files become code blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
|
||||
_MD_EXTS = {"md", "markdown"}
|
||||
|
||||
|
||||
def _ext(path: str) -> str:
|
||||
return Path(path).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def build_repo_result(
|
||||
files: list[tuple[str, str]],
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Turn ``[(path, content)]`` into pages with folder hierarchy."""
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
for path, content in files:
|
||||
clean = path.replace("\\", "/").strip("/")
|
||||
if not clean:
|
||||
continue
|
||||
ext = _ext(clean)
|
||||
if ext in _MD_EXTS:
|
||||
markdown = content
|
||||
else:
|
||||
lang = _CODE_LANG.get(ext, "")
|
||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||
parts = clean.split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=parts[-1] or clean,
|
||||
markdown=markdown,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=f"{provider}:{owner}/{repo}:{clean}",
|
||||
))
|
||||
result.stats["rows"] = len(result.pages)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
async def fetch_forge_repo(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
path: str = "",
|
||||
max_files: int = 200,
|
||||
max_file_bytes: int = 512_000,
|
||||
) -> ImportResult:
|
||||
"""List a repo's files and fetch the textual ones."""
|
||||
try:
|
||||
metas = await adapter.list_repo_files(owner, repo, path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
result.warn(f"Arborescence illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
files: list[tuple[str, str]] = []
|
||||
for meta in metas:
|
||||
file_path = meta.get("path") or ""
|
||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
||||
continue
|
||||
if int(meta.get("size") or 0) > max_file_bytes:
|
||||
continue
|
||||
if len(files) >= max_files:
|
||||
break
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, file_path)
|
||||
except Exception: # noqa: BLE001 - skip unreadable files
|
||||
continue
|
||||
if not content or content == "[binary file]":
|
||||
continue
|
||||
files.append((file_path, content))
|
||||
return build_repo_result(files, owner=owner, repo=repo, provider=provider)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""FlowDeck — HTML notes & Google Keep importer (v5.6.0, Phase 1).
|
||||
|
||||
Covers HTML exports from Apple Notes, Bear, Ulysses and OneNote, plus the
|
||||
Google Takeout ``Keep`` JSON/HTML format. HTML is converted to Markdown and then
|
||||
to FlowDeck blocks by the pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
from app.services.importers._common import coerce_tags, normalize_title
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HTML_EXTS = (".html", ".htm")
|
||||
|
||||
|
||||
def _inline(node: Tag) -> str:
|
||||
out: list[str] = []
|
||||
for child in node.children:
|
||||
if isinstance(child, NavigableString):
|
||||
out.append(str(child))
|
||||
elif isinstance(child, Tag):
|
||||
name = child.name.lower()
|
||||
if name in ("strong", "b"):
|
||||
out.append(f"**{_inline(child).strip()}**")
|
||||
elif name in ("em", "i"):
|
||||
out.append(f"*{_inline(child).strip()}*")
|
||||
elif name == "code":
|
||||
out.append(f"`{child.get_text()}`")
|
||||
elif name == "br":
|
||||
out.append("\n")
|
||||
elif name == "a":
|
||||
href = child.get("href", "")
|
||||
label = _inline(child).strip() or href
|
||||
out.append(f"[{label}]({href})" if href else label)
|
||||
elif name == "img":
|
||||
src = child.get("src", "")
|
||||
alt = child.get("alt", "")
|
||||
out.append(f"" if src else "")
|
||||
elif name in ("del", "s", "strike"):
|
||||
out.append(f"~~{_inline(child).strip()}~~")
|
||||
else:
|
||||
out.append(_inline(child))
|
||||
return re.sub(r"[ \t]+", " ", "".join(out))
|
||||
|
||||
|
||||
def _table(node: Tag) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for tr in node.find_all("tr"):
|
||||
cells = tr.find_all(["th", "td"])
|
||||
rows.append([_inline(c).strip().replace("|", "\\|") for c in cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
def _block(node: Tag, depth: int = 0) -> str:
|
||||
name = node.name.lower()
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
return "#" * int(name[1]) + " " + _inline(node).strip()
|
||||
if name == "p":
|
||||
return _inline(node).strip()
|
||||
if name in ("ul", "ol"):
|
||||
lines = []
|
||||
for i, li in enumerate(node.find_all("li", recursive=False)):
|
||||
marker = f"{i + 1}." if name == "ol" else "-"
|
||||
text = _inline(li).strip()
|
||||
lines.append(f"{' ' * depth}{marker} {text}")
|
||||
return "\n".join(lines)
|
||||
if name == "blockquote":
|
||||
return "\n".join(f"> {ln}" for ln in _inline(node).strip().splitlines())
|
||||
if name == "pre":
|
||||
code = node.get_text()
|
||||
lang = ""
|
||||
cls = " ".join(node.get("class", [])) if node.get("class") else ""
|
||||
m = re.search(r"(?:language|lang)-([\w+-]+)", cls)
|
||||
if m:
|
||||
lang = m.group(1)
|
||||
return f"```{lang}\n{code.rstrip()}\n```"
|
||||
if name == "hr":
|
||||
return "---"
|
||||
if name == "table":
|
||||
return _table(node)
|
||||
if name == "img":
|
||||
src = node.get("src", "")
|
||||
return f"" if src else ""
|
||||
if name in ("div", "section", "article", "body", "main", "html", "span", "font", "center"):
|
||||
inner = "\n\n".join(
|
||||
_block(c, depth) for c in node.children if isinstance(c, Tag)
|
||||
).strip()
|
||||
if inner:
|
||||
return inner
|
||||
text = _inline(node).strip()
|
||||
return text
|
||||
return _inline(node).strip()
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "head", "nav", "footer"]):
|
||||
tag.decompose()
|
||||
root = soup.body or soup
|
||||
blocks = [_block(c) for c in root.children if isinstance(c, Tag)]
|
||||
md = "\n\n".join(b for b in blocks if b and b.strip())
|
||||
return re.sub(r"\n{3,}", "\n\n", md).strip()
|
||||
|
||||
|
||||
def _title_from_html(html: str, fallback: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
h1 = soup.find(["h1", "h2"])
|
||||
if h1:
|
||||
return h1.get_text().strip()
|
||||
return fallback
|
||||
|
||||
|
||||
@register_importer
|
||||
class HtmlNotesImporter(Importer):
|
||||
source_id = "html_notes"
|
||||
label = "HTML (Apple Notes, Bear, Ulysses, OneNote)"
|
||||
description = "Fichiers HTML ou archive .zip (notes exportées en HTML)."
|
||||
extensions = (".html", ".htm", ".zip")
|
||||
order = 50
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_HTML_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return any(n.lower().endswith(_HTML_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
entries: list[tuple[str, bytes]] = []
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(_HTML_EXTS):
|
||||
entries.append((clean, zf.read(name)))
|
||||
else:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
else:
|
||||
entries.append((filename, data))
|
||||
|
||||
for name, payload in entries:
|
||||
html = decode_text(payload)
|
||||
fallback = name.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_html(html, fallback) or "Untitled",
|
||||
markdown=_html_to_markdown(html),
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class GoogleKeepImporter(Importer):
|
||||
source_id = "google_keep"
|
||||
label = "Google Keep (Takeout)"
|
||||
description = "Export Google Takeout : Keep/*.json (notes, listes, labels, pièces jointes)."
|
||||
extensions = (".json", ".zip")
|
||||
order = 40
|
||||
|
||||
def _is_keep_json(self, data: bytes) -> bool:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return isinstance(obj, dict) and any(
|
||||
k in obj for k in ("textContent", "listContent", "isTrashed", "color")
|
||||
)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(".json"):
|
||||
return self._is_keep_json(data)
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
for n in zf.namelist():
|
||||
if n.lower().endswith(".json") and "keep" in n.lower():
|
||||
try:
|
||||
if self._is_keep_json(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def _page_from_keep(self, obj: dict, name: str) -> ImportPage | None:
|
||||
if obj.get("isTrashed"):
|
||||
return None
|
||||
title = normalize_title(obj.get("title"))
|
||||
lines: list[str] = []
|
||||
for item in obj.get("listContent") or []:
|
||||
mark = "x" if item.get("isChecked") else " "
|
||||
lines.append(f"- [{mark}] {item.get('text', '')}")
|
||||
if obj.get("textContent"):
|
||||
lines.insert(0, obj["textContent"])
|
||||
body = "\n\n".join(lines)
|
||||
if not title:
|
||||
first = next((ln for ln in body.splitlines() if ln.strip()), "")
|
||||
first = re.sub(r"^[-*+]\s*(\[[ xX]\]\s*)?", "", first).strip()
|
||||
title = first[:60] or "Note"
|
||||
labels = coerce_tags(obj.get("labels"))
|
||||
props = {"tags": labels} if labels else {}
|
||||
return ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="",
|
||||
properties=props,
|
||||
external_id=name,
|
||||
)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(".json") and "keep" in clean.lower():
|
||||
try:
|
||||
obj = json.loads(decode_text(zf.read(name)))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
page = self._page_from_keep(obj, clean)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
elif "/keep/" in clean.lower() and not clean.lower().endswith(".json"):
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"JSON invalide : {exc}")
|
||||
return result.finalize()
|
||||
if isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
if isinstance(item, dict):
|
||||
page = self._page_from_keep(item, f"{filename}#{i}")
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
else:
|
||||
page = self._page_from_keep(obj, filename)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""FlowDeck — background import jobs (v5.6.0, Phase 0).
|
||||
|
||||
Small in-process job manager used for large uploads (vaults, zips): the upload
|
||||
is parsed and persisted in a worker thread while the UI polls job status.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
)
|
||||
from app.services.importers.pipeline import run_import
|
||||
|
||||
_JOBS: dict[str, dict[str, Any]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def parse_upload(filename: str, data: bytes, source_id: str | None = None) -> tuple[Importer | None, ImportResult]:
|
||||
"""Detect (or use) an importer and parse the upload synchronously."""
|
||||
imp = get_importer(source_id) if source_id else None
|
||||
if imp is None:
|
||||
imp = detect_importer(filename, data)
|
||||
if imp is None:
|
||||
return None, ImportResult(source=source_id or "unknown", warnings=["Format non reconnu"])
|
||||
return imp, imp.parse(filename, data)
|
||||
|
||||
|
||||
def _record(job: dict, *, status: str | None = None, error: str = "",
|
||||
report: dict | None = None, progress: int | None = None) -> None:
|
||||
with _LOCK:
|
||||
if status:
|
||||
job["status"] = status
|
||||
if error:
|
||||
job["error"] = error
|
||||
if report is not None:
|
||||
job["report"] = report
|
||||
if progress is not None:
|
||||
job["progress"] = progress
|
||||
job["updated_at"] = time.time()
|
||||
_persist(job)
|
||||
|
||||
|
||||
def _persist(job: dict) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO import_jobs (id, source, filename, status, error, report_json, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, "
|
||||
"report_json=excluded.report_json, updated_at=excluded.updated_at",
|
||||
(job["id"], job["source"], job["filename"], job["status"], job.get("error", ""),
|
||||
_json(job.get("report")), job["created_at"], job["updated_at"]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 - persistence is best-effort
|
||||
pass
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def create_job(source: str, filename: str) -> dict:
|
||||
job = {
|
||||
"id": uuid.uuid4().hex[:16],
|
||||
"source": source,
|
||||
"filename": filename,
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"error": "",
|
||||
"report": None,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _LOCK:
|
||||
_JOBS[job["id"]] = job
|
||||
_persist(job)
|
||||
return job
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict | None:
|
||||
with _LOCK:
|
||||
job = _JOBS.get(job_id)
|
||||
return dict(job) if job else None
|
||||
|
||||
|
||||
def list_jobs(limit: int = 50) -> list[dict]:
|
||||
with _LOCK:
|
||||
jobs = sorted(_JOBS.values(), key=lambda j: j["created_at"], reverse=True)
|
||||
return [dict(j) for j in jobs[:limit]]
|
||||
|
||||
|
||||
def start_import_job(
|
||||
*,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
source_id: str | None,
|
||||
workspace_id: int | None,
|
||||
workspace_name: str | None,
|
||||
user_login: str,
|
||||
parent_page_id: int | None,
|
||||
target_collection_id: int | None,
|
||||
dedup: bool = True,
|
||||
mapping: dict[str, str] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a job and run parse + persist in a background thread."""
|
||||
job = create_job(source_id or "auto", filename)
|
||||
_record(job, status="running", progress=5)
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
_record(job, status="error", error="Format non reconnu")
|
||||
return
|
||||
_record(job, progress=40)
|
||||
report = run_import(
|
||||
result,
|
||||
workspace_id=workspace_id,
|
||||
workspace_name=workspace_name,
|
||||
user_login=user_login,
|
||||
parent_page_id=parent_page_id,
|
||||
target_collection_id=target_collection_id,
|
||||
dedup=dedup,
|
||||
mapping=mapping,
|
||||
mode=mode,
|
||||
)
|
||||
_record(job, status="done", progress=100, report=report)
|
||||
except Exception as exc: # noqa: BLE001 - surface the error to the UI
|
||||
_record(job, status="error", error=f"{exc}", report={
|
||||
"traceback": traceback.format_exc()[-2000:],
|
||||
})
|
||||
|
||||
threading.Thread(target=worker, name=f"import-{job['id']}", daemon=True).start()
|
||||
return job
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — generic Markdown / text importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MD_EXTS = (".md", ".markdown", ".txt", ".mdx")
|
||||
|
||||
|
||||
def _title_from_name(name: str) -> str:
|
||||
base = name.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
for ext in (".markdown", ".markdown", ".mdx", ".md", ".txt"):
|
||||
if base.lower().endswith(ext):
|
||||
base = base[: -len(ext)]
|
||||
break
|
||||
return base.strip() or "Untitled"
|
||||
|
||||
|
||||
@register_importer
|
||||
class MarkdownImporter(Importer):
|
||||
source_id = "markdown"
|
||||
label = "Markdown / texte"
|
||||
description = "Fichiers .md/.markdown/.txt ou archive .zip de fichiers Markdown."
|
||||
extensions = (".md", ".markdown", ".txt", ".zip")
|
||||
order = 90
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_MD_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return bool(names) and all(n.lower().endswith(_MD_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
entries = sorted(
|
||||
(n for n in zf.namelist()
|
||||
if not n.endswith("/") and n.lower().endswith(_MD_EXTS)),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not entries:
|
||||
result.warn("Aucun fichier Markdown trouvé dans l'archive")
|
||||
return result.finalize()
|
||||
for name in entries:
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(name),
|
||||
markdown=text,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(filename),
|
||||
markdown=text,
|
||||
source_path=filename,
|
||||
external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — Notion export importer (v5.6.0, Phase 1, amélioration v5.4.0).
|
||||
|
||||
Imports a Notion "Export as Markdown & CSV" ``.zip``: complete page hierarchy,
|
||||
databases (``.csv``) turned into FlowDeck collections, and image attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
from app.services.importers._common import split_frontmatter
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
from app.services.importers.tabular import rows_to_collection
|
||||
|
||||
_HASH_RE = re.compile(r"\s+[0-9a-f]{32}$")
|
||||
_MD_LINK_RE = re.compile(r"\]\(([^)]+)\.md\)")
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
base = unquote(name.replace("\\", "/").rsplit("/", 1)[-1])
|
||||
base = re.sub(r"\.(md|csv|markdown)$", "", base, flags=re.IGNORECASE)
|
||||
return _HASH_RE.sub("", base).strip() or "Untitled"
|
||||
|
||||
|
||||
def _strip_hash_link(match: re.Match) -> str:
|
||||
target = unquote(match.group(1)).strip()
|
||||
return f"]({_HASH_RE.sub('', target).strip() or target})"
|
||||
|
||||
|
||||
@register_importer
|
||||
class NotionImporter(Importer):
|
||||
source_id = "notion"
|
||||
label = "Notion (export .zip)"
|
||||
description = "Export Notion Markdown & CSV : hiérarchie, databases → collections, images."
|
||||
extensions = (".zip",)
|
||||
order = 20
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
csvs = [n for n in names if n.lower().endswith(".csv")]
|
||||
if not md:
|
||||
return False
|
||||
if csvs:
|
||||
return True
|
||||
return any(_HASH_RE.search(unquote(n.rsplit("/", 1)[-1])) for n in md)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md_names = [n for n in names if n.lower().endswith(".md")]
|
||||
csv_names = [n for n in names if n.lower().endswith(".csv")]
|
||||
|
||||
pages_by_title: dict[str, ImportPage] = {}
|
||||
for name in sorted(md_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
title = _clean_name(name)
|
||||
body = _MD_LINK_RE.sub(_strip_hash_link, body)
|
||||
first_line = body.lstrip().splitlines()[0] if body.strip() else ""
|
||||
if first_line.strip().startswith("# ") and first_line.strip()[2:].strip() == title:
|
||||
body = "\n".join(body.lstrip().splitlines()[1:]).lstrip("\n")
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
page = ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties={k: v for k, v in meta.items() if k != "title"},
|
||||
external_id=name,
|
||||
)
|
||||
pages_by_title.setdefault(title, page)
|
||||
result.pages.append(page)
|
||||
|
||||
for name in sorted(csv_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
||||
rows = [dict(r) for r in reader]
|
||||
title = _clean_name(name)
|
||||
spec_page = rows_to_collection(title, headers, rows)
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
existing = pages_by_title.get(title)
|
||||
if existing is not None:
|
||||
existing.collection = spec_page.collection
|
||||
existing.source_path = existing.source_path or name
|
||||
else:
|
||||
spec_page.parent_path = "/".join(parts[:-1])
|
||||
spec_page.source_path = name
|
||||
spec_page.external_id = name
|
||||
result.pages.append(spec_page)
|
||||
|
||||
for name in names:
|
||||
low = name.lower()
|
||||
if low.endswith((".md", ".csv")):
|
||||
continue
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=unquote(name).replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""FlowDeck — Obsidian vault importer (v5.6.0, Phase 1).
|
||||
|
||||
Imports a vault exported as a ``.zip``: Markdown notes (with YAML frontmatter),
|
||||
the folder hierarchy, ``[[wikilinks]]``/``![[embeds]]`` and binary attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SKIP_DIRS = (".obsidian/", ".trash/", ".git/", ".DS_Store")
|
||||
|
||||
|
||||
def _mime_for(name: str) -> str:
|
||||
import mimetypes
|
||||
|
||||
return mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
|
||||
|
||||
@register_importer
|
||||
class ObsidianImporter(Importer):
|
||||
source_id = "obsidian"
|
||||
label = "Obsidian (vault .zip)"
|
||||
description = "Vault Obsidian : notes Markdown, frontmatter YAML, wikilinks, pièces jointes."
|
||||
extensions = (".zip",)
|
||||
order = 10
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any("/.obsidian/" in n or n.startswith(".obsidian/") for n in names):
|
||||
return True
|
||||
# Heuristic: mostly-markdown archive containing wikilinks.
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
if not md:
|
||||
return False
|
||||
for n in md[:20]:
|
||||
try:
|
||||
if "[[" in decode_text(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
notes = [n for n in names if n.lower().endswith(".md")]
|
||||
assets = [n for n in names if not n.lower().endswith(".md")]
|
||||
|
||||
for name in assets:
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS) or clean.split("/")[-1].startswith("."):
|
||||
continue
|
||||
try:
|
||||
payload = zf.read(name)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=payload,
|
||||
mime=_mime_for(name),
|
||||
))
|
||||
|
||||
for name in sorted(notes, key=lambda n: (n.count("/"), n.lower())):
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS):
|
||||
continue
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
body = convert_wikilinks(body)
|
||||
parts = clean.split("/")
|
||||
title = normalize_title(meta.get("title")) or parts[-1][:-3]
|
||||
props = dict(meta)
|
||||
props.pop("title", None)
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=clean,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""FlowDeck — OPML importer (v5.6.0, Phase 4).
|
||||
|
||||
Imports an OPML outline (RSS readers, feed lists) as a collection of feeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Feed URL", "type": "url"},
|
||||
{"name": "Site URL", "type": "url"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "rss", "color": "orange"},
|
||||
{"name": "folder", "color": "gray"},
|
||||
]},
|
||||
{"name": "Folder", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
@register_importer
|
||||
class OpmlImporter(Importer):
|
||||
source_id = "opml"
|
||||
label = "OPML (flux RSS)"
|
||||
description = "Outline OPML → collection de flux (titre, URL, dossier)."
|
||||
extensions = (".opml", ".xml")
|
||||
order = 34
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith(".opml"):
|
||||
return True
|
||||
head = decode_text(data)[:1000].lower()
|
||||
return "<opml" in head and "<outline" in head
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
root = ET.fromstring(decode_text(data))
|
||||
except ET.ParseError as exc:
|
||||
result.warn(f"OPML invalide : {exc}")
|
||||
return result.finalize()
|
||||
for outline in root.iter("outline"):
|
||||
attrs = {k.lower(): v for k, v in outline.attrib.items()}
|
||||
feed = attrs.get("xmlurl")
|
||||
title = attrs.get("title") or attrs.get("text") or feed or ""
|
||||
if not feed and not title:
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if feed:
|
||||
props["Feed URL"] = feed
|
||||
props["Type"] = "rss"
|
||||
else:
|
||||
props["Type"] = "folder"
|
||||
if attrs.get("htmlurl"):
|
||||
props["Site URL"] = attrs["htmlurl"]
|
||||
folder = _folder_of(outline, root)
|
||||
if folder:
|
||||
props["Folder"] = folder
|
||||
rows.append({"title": title[:200] or "Feed", "properties": props})
|
||||
result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _folder_of(node: ET.Element, root: ET.Element) -> str:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
parts: list[str] = []
|
||||
current = parents.get(node)
|
||||
while current is not None:
|
||||
attrs = {k.lower(): v for k, v in current.attrib.items()}
|
||||
if not attrs.get("xmlurl"):
|
||||
label = attrs.get("title") or attrs.get("text")
|
||||
if label:
|
||||
parts.append(label)
|
||||
current = parents.get(current)
|
||||
return " / ".join(reversed(parts))
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Logseq / Roam Research outliner importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_LOGSEQ_MARKERS = ("property::", "logseq/", "journals/")
|
||||
_ROAM_MARKERS = ("{{[[TODO]]}}", "{{[[DONE]]}}", "{{[[query]]}}")
|
||||
_PROP_RE = re.compile(r"^\s*([a-zA-Z][\w-]*)::\s*(.*)$")
|
||||
|
||||
|
||||
def _journal_title(name: str) -> str:
|
||||
m = re.match(r"^(\d{4})[_-](\d{2})[_-](\d{2})", name)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
return name
|
||||
|
||||
|
||||
def _clean_outline(text: str) -> tuple[dict, str]:
|
||||
meta, body = split_frontmatter(text)
|
||||
lines_out: list[str] = []
|
||||
for line in body.splitlines():
|
||||
m = _PROP_RE.match(line)
|
||||
if m and line.lstrip().startswith("-"):
|
||||
continue
|
||||
# Logseq properties appear as bare ``key:: value`` lines too.
|
||||
m2 = _PROP_RE.match(line)
|
||||
if m2 and not line.lstrip().startswith(("-", "*", "#", "|")):
|
||||
key = m2.group(1)
|
||||
if key not in meta:
|
||||
meta[key] = m2.group(2).strip()
|
||||
continue
|
||||
lines_out.append(line)
|
||||
body = "\n".join(lines_out)
|
||||
# Roam task markers → GFM checkboxes.
|
||||
body = body.replace("{{[[TODO]]}}", "[ ] ").replace("{{[[DONE]]}}", "[x] ")
|
||||
# Block references ((uuid)) → plain anchors.
|
||||
body = re.sub(r"\(\(([0-9a-fA-F-]{6,})\)\)", r"[[\1]]", body)
|
||||
body = convert_wikilinks(body)
|
||||
return meta, body
|
||||
|
||||
|
||||
class _OutlineBase(Importer):
|
||||
markers: tuple[str, ...] = ()
|
||||
property_syntax = False
|
||||
source_id = "outline"
|
||||
label = "Outliner"
|
||||
description = ""
|
||||
order = 30
|
||||
|
||||
def _text_matches(self, text: str) -> bool:
|
||||
if any(m in text for m in self.markers if not m.endswith("/")):
|
||||
return True
|
||||
return bool(self.property_syntax and re.search(r"^\s*[a-zA-Z][\w-]*::", text, re.M))
|
||||
|
||||
def _looks_like(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith((".md", ".markdown", ".txt")):
|
||||
return self._text_matches(decode_text(data))
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any(m in name for m in self.markers if m.endswith("/") for name in names):
|
||||
return True
|
||||
for n in [x for x in names if x.lower().endswith(".md")][:10]:
|
||||
try:
|
||||
if self._text_matches(decode_text(zf.read(n))):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return self._looks_like(filename, data)
|
||||
|
||||
def _emit(self, result: ImportResult, name: str, text: str, is_journal: bool) -> None:
|
||||
meta, body = _clean_outline(text)
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
raw_title = parts[-1].rsplit(".", 1)[0]
|
||||
title = normalize_title(meta.get("title")) or (
|
||||
_journal_title(raw_title) if is_journal else raw_title
|
||||
)
|
||||
props = {k: v for k, v in meta.items() if k != "title"}
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=name,
|
||||
))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
for name in [n for n in names if not n.lower().endswith(".md")]:
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=name.replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for name in sorted(
|
||||
(n for n in names if n.lower().endswith(".md")),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
self._emit(result, name, text, is_journal="journal" in name.lower())
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
self._emit(result, filename, text, is_journal=False)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class LogseqImporter(_OutlineBase):
|
||||
source_id = "logseq"
|
||||
label = "Logseq"
|
||||
description = "Outliner Logseq : pages/journal, propriétés `key:: value`, block refs."
|
||||
markers = ("property::", "logseq/", "journals/")
|
||||
property_syntax = True
|
||||
|
||||
|
||||
@register_importer
|
||||
class RoamImporter(_OutlineBase):
|
||||
source_id = "roam"
|
||||
label = "Roam Research"
|
||||
description = "Outliner Roam : `{{[[TODO]]}}`, block refs, wikilinks."
|
||||
markers = _ROAM_MARKERS
|
||||
@@ -0,0 +1,94 @@
|
||||
"""FlowDeck — PDF importer (v5.6.0, Phase 3).
|
||||
|
||||
Best-effort text + image extraction from a PDF into a FlowDeck page (fidelity
|
||||
depends on the source PDF; scanned documents have no text layer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".tiff": "image/tiff", ".tif": "image/tiff"}
|
||||
|
||||
|
||||
@register_importer
|
||||
class PdfImporter(Importer):
|
||||
source_id = "pdf"
|
||||
label = "PDF"
|
||||
description = "Extraction texte + images d'un PDF (fidélité limitée)."
|
||||
extensions = (".pdf",)
|
||||
order = 37
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
result.warn("pypdf n'est pas installé : import PDF indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"PDF illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
chunks: list[str] = []
|
||||
empty_pages = 0
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
try:
|
||||
text = (page.extract_text() or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
if text:
|
||||
if len(reader.pages) > 1:
|
||||
chunks.append(f"## Page {index}\n\n{text}")
|
||||
else:
|
||||
chunks.append(text)
|
||||
else:
|
||||
empty_pages += 1
|
||||
chunks.extend(self._page_images(page, index, result))
|
||||
|
||||
if empty_pages:
|
||||
result.warn(f"{empty_pages} page(s) sans couche texte (document scanné ?)")
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(chunks)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(ImportPage(
|
||||
title=title, markdown=markdown, source_path=filename, external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
def _page_images(self, page, index: int, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
try:
|
||||
page_images = list(page.images)
|
||||
except Exception: # noqa: BLE001
|
||||
return images
|
||||
for i, image in enumerate(page_images, start=1):
|
||||
name = getattr(image, "name", "") or f"page{index}_img{i}.png"
|
||||
name = Path(name).name
|
||||
try:
|
||||
payload = image.data
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not payload:
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=f"page{index}/{name}", filename=name, data=payload,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
@@ -0,0 +1,566 @@
|
||||
"""FlowDeck — common import pipeline (v5.6.0, Phase 0).
|
||||
|
||||
Persists an :class:`~app.services.importers.base.ImportResult` into FlowDeck:
|
||||
resolves the workspace, rebuilds the folder hierarchy (``parent_id``), stores
|
||||
attachments, rewrites links, creates collections + rows, and records imported
|
||||
items for idempotent re-imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.tabular import apply_type_mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
base = Path(name.replace("\\", "/")).name
|
||||
base = re.sub(r"[^\w.\- ()]+", "_", base).strip() or "file"
|
||||
return base[:150]
|
||||
|
||||
|
||||
def _sha1(*parts: str) -> str:
|
||||
return hashlib.sha1("||".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _resolve_workspace(conn, workspace_id: int | None, workspace_name: str | None,
|
||||
user_login: str) -> tuple[int | None, str]:
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
name = workspace_name or user_login
|
||||
if name:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE name=?", (name,)).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
return workspace_id, name or ""
|
||||
|
||||
|
||||
def _rewrite_links(text: str, attachment_map: dict[str, str]) -> str:
|
||||
"""Point Markdown links/images at uploaded attachment URLs."""
|
||||
def repl(m: re.Match) -> str:
|
||||
alt, target = m.group(1), m.group(2)
|
||||
url = attachment_map.get(target) or attachment_map.get(Path(target).name.lower())
|
||||
return f"" if url else m.group(0)
|
||||
|
||||
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def _extract_media(blocks: list[dict]) -> list[dict]:
|
||||
"""Split inline image markdown out of paragraphs into real image blocks."""
|
||||
out: list[dict] = []
|
||||
for block in blocks:
|
||||
if block.get("type") != "paragraph":
|
||||
out.append(block)
|
||||
continue
|
||||
content = str(block.get("content", ""))
|
||||
pos = 0
|
||||
found = False
|
||||
for match in _IMG_RE.finditer(content):
|
||||
found = True
|
||||
before = content[pos:match.start()].strip()
|
||||
if before:
|
||||
out.append({"type": "paragraph", "content": before})
|
||||
out.append({"type": "image", "src": match.group(2), "alt": match.group(1)})
|
||||
pos = match.end()
|
||||
if not found:
|
||||
out.append(block)
|
||||
continue
|
||||
tail = content[pos:].strip()
|
||||
if tail:
|
||||
out.append({"type": "paragraph", "content": tail})
|
||||
return out
|
||||
|
||||
|
||||
def _properties_callout(props: dict) -> dict | None:
|
||||
if not props:
|
||||
return None
|
||||
lines = [f"**{k}** : {', '.join(map(str, v)) if isinstance(v, list) else v}" for k, v in props.items()]
|
||||
return {"type": "callout", "icon": "ℹ️", "content": "\n".join(lines)}
|
||||
|
||||
|
||||
def _blocks_for(page: ImportPage, attachment_map: dict[str, str], *, include_properties: bool) -> list[dict]:
|
||||
if page.blocks:
|
||||
return _extract_media(page.blocks)
|
||||
md = _rewrite_links(page.markdown or "", attachment_map)
|
||||
blocks = _extract_media(markdown_to_blocks(md))
|
||||
if include_properties and page.properties:
|
||||
callout = _properties_callout(page.properties)
|
||||
if callout:
|
||||
blocks.insert(0, callout)
|
||||
return blocks
|
||||
|
||||
|
||||
def preview_result(result: ImportResult) -> dict[str, Any]:
|
||||
"""Dry-run preview: what would be created, without touching the database."""
|
||||
pages = []
|
||||
for page in result.pages:
|
||||
if page.collection:
|
||||
kind = "collection"
|
||||
rows = len(page.collection.get("rows", []))
|
||||
blocks = len(_blocks_for(page, {}, include_properties=False))
|
||||
schema = page.collection.get("schema", [])
|
||||
else:
|
||||
kind = "page"
|
||||
rows = 0
|
||||
blocks = len(_blocks_for(page, {}, include_properties=False))
|
||||
schema = []
|
||||
pages.append({
|
||||
"title": page.title,
|
||||
"type": kind,
|
||||
"source_path": page.source_path,
|
||||
"parent_path": page.parent_path,
|
||||
"properties": list(page.properties.keys()),
|
||||
"rows": rows,
|
||||
"blocks": blocks,
|
||||
"schema": schema,
|
||||
})
|
||||
return {
|
||||
"source": result.source,
|
||||
"dry_run": True,
|
||||
"pages": pages,
|
||||
"stats": {
|
||||
**result.stats,
|
||||
"pages": len(result.pages),
|
||||
"collections": sum(1 for p in result.pages if p.collection),
|
||||
"attachments": len(result.attachments),
|
||||
"warnings": len(result.warnings),
|
||||
},
|
||||
"warnings": result.warnings,
|
||||
}
|
||||
|
||||
|
||||
def _insert_page(conn, *, workspace: str, workspace_id: int | None, title: str,
|
||||
blocks: list[dict], parent_id: int | None, sort_order: int,
|
||||
content_format: str = "blocks") -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, "
|
||||
"sort_order, workspace_id, parent_id) VALUES (?,?,?,?,'Private',?,?,?)",
|
||||
(workspace, title or "Untitled", json.dumps(blocks), content_format,
|
||||
sort_order, workspace_id, parent_id),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _prop_id_map(conn, collection_id: int) -> dict[str, int]:
|
||||
return {
|
||||
r["name"]: r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
|
||||
def _rows_to_values(rows: list[dict], id_map: dict[str, int]) -> list[tuple[str, dict]]:
|
||||
"""Key row properties by property id (the shape the editor reads)."""
|
||||
out: list[tuple[str, dict]] = []
|
||||
for row in rows:
|
||||
values: dict[str, Any] = {}
|
||||
for name, value in (row.get("properties") or {}).items():
|
||||
prop_id = id_map.get(name)
|
||||
if prop_id is not None:
|
||||
values[str(prop_id)] = value
|
||||
out.append((row.get("title") or "Untitled", values))
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_default_view(conn, collection_id: int) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, "Default View", "table",
|
||||
json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
|
||||
)
|
||||
|
||||
|
||||
def _insert_collection(conn, page: ImportPage, *, workspace_id: int | None,
|
||||
parent_page_id: int | None) -> tuple[int, int]:
|
||||
spec = page.collection or {}
|
||||
schema = spec.get("schema", [])
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json, is_inline, "
|
||||
"parent_page_id, workspace_id) VALUES (?,?,?,?,1,?,?)",
|
||||
(page.title or spec.get("name") or "Imported database", "", "📥",
|
||||
json.dumps(schema), parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
_ensure_default_view(conn, collection_id)
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
position = 0
|
||||
for title, values in _rows_to_values(spec.get("rows", []), id_map):
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, title, position, json.dumps(values)),
|
||||
)
|
||||
position += 1
|
||||
return collection_id, position
|
||||
|
||||
|
||||
def _upsert_collection_rows(conn, collection_id: int, rows: list[dict]) -> tuple[int, int]:
|
||||
"""Update existing rows by title, insert the new ones. Returns (created, updated)."""
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
existing = {
|
||||
(r["title"] or "").strip(): r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
}
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
created = updated = 0
|
||||
for title, values in _rows_to_values(rows, id_map):
|
||||
pid = existing.get((title or "").strip())
|
||||
if pid:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), pid),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
max_pos += 1
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, title, max_pos, json.dumps(values)),
|
||||
)
|
||||
created += 1
|
||||
return created, updated
|
||||
|
||||
|
||||
def _update_page(conn, page_id: int, title: str, blocks: list[dict]) -> None:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, content=?, content_format='blocks', "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(title or "Untitled", json.dumps(blocks), page_id),
|
||||
)
|
||||
|
||||
|
||||
def run_import(
|
||||
result: ImportResult,
|
||||
*,
|
||||
workspace_id: int | None = None,
|
||||
workspace_name: str | None = None,
|
||||
user_login: str = "",
|
||||
parent_page_id: int | None = None,
|
||||
target_collection_id: int | None = None,
|
||||
dry_run: bool = False,
|
||||
dedup: bool = True,
|
||||
include_properties: bool = True,
|
||||
mapping: dict[str, str] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist an import result. Returns a report dict.
|
||||
|
||||
``mode`` controls re-import behaviour for items already imported (matched by
|
||||
``import_items``): ``skip`` (default), ``update`` (re-sync in place) or
|
||||
``duplicate`` (always create a new page).
|
||||
"""
|
||||
if dry_run:
|
||||
return preview_result(result)
|
||||
|
||||
if mapping:
|
||||
for page in result.pages:
|
||||
if page.collection:
|
||||
apply_type_mapping(page.collection, mapping)
|
||||
|
||||
if not mode:
|
||||
mode = "skip" if dedup else "duplicate"
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"source": result.source,
|
||||
"status": "ok",
|
||||
"mode": mode,
|
||||
"pages_created": 0,
|
||||
"pages_updated": 0,
|
||||
"collections_created": 0,
|
||||
"rows_created": 0,
|
||||
"rows_updated": 0,
|
||||
"attachments": 0,
|
||||
"skipped": 0,
|
||||
"page_ids": [],
|
||||
"errors": [],
|
||||
"warnings": list(result.warnings),
|
||||
}
|
||||
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_name = _resolve_workspace(conn, workspace_id, workspace_name, user_login)
|
||||
if not ws_name:
|
||||
report["status"] = "error"
|
||||
report["warnings"].append("Workspace introuvable")
|
||||
return report
|
||||
|
||||
attachment_map: dict[str, str] = {}
|
||||
if result.attachments and ws_id:
|
||||
dest_dir = _data_dir() / "uploads" / f"workspace_{ws_id}" / "import"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for att in result.attachments:
|
||||
safe = _safe_filename(att.filename)
|
||||
target = dest_dir / safe
|
||||
if target.exists():
|
||||
target = dest_dir / f"{_sha1(att.source_path)[:8]}_{safe}"
|
||||
try:
|
||||
target.write_bytes(att.data)
|
||||
except OSError:
|
||||
continue
|
||||
url = f"/api/files/{ws_id}/import/{target.name}"
|
||||
attachment_map[att.source_path] = url
|
||||
attachment_map[att.source_path.lower()] = url
|
||||
attachment_map[Path(att.source_path).name.lower()] = url
|
||||
report["attachments"] += 1
|
||||
|
||||
if target_collection_id:
|
||||
return _import_into_collection(
|
||||
conn, result, target_collection_id, report, attachment_map,
|
||||
dedup=dedup, workspace_id=ws_id, include_properties=include_properties,
|
||||
)
|
||||
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_name,),
|
||||
).fetchone()[0]
|
||||
order_counter = [next_order]
|
||||
path_to_page: dict[str, int] = {}
|
||||
folder_cache: dict[str, int | None] = {}
|
||||
|
||||
def ensure_folder(path: str, _depth: int = 0) -> int | None:
|
||||
path = (path or "").strip("/")
|
||||
if not path:
|
||||
return parent_page_id
|
||||
if path in folder_cache:
|
||||
return folder_cache[path]
|
||||
parent_path = "/".join(path.split("/")[:-1])
|
||||
parent_id = ensure_folder(parent_path, _depth + 1)
|
||||
title = path.split("/")[-1] or "Folder"
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=title,
|
||||
blocks=[], parent_id=parent_id, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
folder_cache[path] = pid
|
||||
path_to_page[path] = pid
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
return pid
|
||||
|
||||
ordered = sorted(
|
||||
result.pages,
|
||||
key=lambda p: (p.parent_path.count("/") if p.parent_path else -1, p.source_path.lower()),
|
||||
)
|
||||
for page in ordered:
|
||||
external = page.external_id or page.source_path or page.title
|
||||
existing_pid = None
|
||||
if external:
|
||||
row = conn.execute(
|
||||
"SELECT page_id FROM import_items WHERE workspace_id IS ? AND source=? AND external_id=?",
|
||||
(ws_id, result.source, external),
|
||||
).fetchone()
|
||||
if row:
|
||||
existing_pid = row["page_id"]
|
||||
if existing_pid and mode == "skip":
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page_parent = ensure_folder(page.parent_path) if page.parent_path else parent_page_id
|
||||
blocks = _blocks_for(page, attachment_map, include_properties=include_properties)
|
||||
|
||||
if existing_pid and mode == "update":
|
||||
if page.collection:
|
||||
cid = _collection_for_page(conn, existing_pid)
|
||||
if cid:
|
||||
materialize_properties(conn, cid, (page.collection or {}).get("schema", []))
|
||||
created, updated = _upsert_collection_rows(
|
||||
conn, cid, (page.collection or {}).get("rows", []))
|
||||
report["rows_created"] += created
|
||||
report["rows_updated"] += updated
|
||||
blocks = blocks + [{"type": "embed", "embed_type": "collection",
|
||||
"collection_id": cid, "content": ""}]
|
||||
_update_page(conn, existing_pid, page.title, blocks)
|
||||
report["pages_updated"] += 1
|
||||
report["page_ids"].append(existing_pid)
|
||||
path_to_page[page.source_path] = existing_pid
|
||||
continue
|
||||
|
||||
if page.collection:
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
|
||||
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
cid, rows = _insert_collection(conn, page, workspace_id=ws_id, parent_page_id=pid)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=? WHERE id=?",
|
||||
(json.dumps(blocks + [{"type": "embed", "embed_type": "collection",
|
||||
"collection_id": cid, "content": ""}]), pid),
|
||||
)
|
||||
report["collections_created"] += 1
|
||||
report["rows_created"] += rows
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
else:
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
|
||||
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
|
||||
path_to_page[page.source_path] = pid
|
||||
if external:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO import_items (workspace_id, source, external_id, page_id) VALUES (?,?,?,?)",
|
||||
(ws_id, result.source, external, pid),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - partial import must keep going
|
||||
logger.warning("import failed for %r: %s", page.title, exc)
|
||||
report["errors"].append({"title": page.title, "error": str(exc)})
|
||||
conn.commit()
|
||||
if report["errors"]:
|
||||
report["status"] = "partial"
|
||||
return report
|
||||
|
||||
|
||||
def _collection_for_page(conn, page_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collections WHERE parent_page_id=? ORDER BY id LIMIT 1", (page_id,)
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
def _import_into_collection(conn, result: ImportResult, collection_id: int, report: dict,
|
||||
attachment_map: dict[str, str], *, dedup: bool,
|
||||
workspace_id: int | None, include_properties: bool) -> dict:
|
||||
exists = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not exists:
|
||||
report["status"] = "error"
|
||||
report["warnings"].append("Collection cible introuvable")
|
||||
return report
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
for page in result.pages:
|
||||
external = page.external_id or page.source_path or page.title
|
||||
if dedup and external:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE collection_id=? AND title=?",
|
||||
(collection_id, page.title),
|
||||
).fetchone()
|
||||
if row:
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
props = {
|
||||
str(id_map[name]): value
|
||||
for name, value in page.properties.items()
|
||||
if name in id_map
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, page.title, max_pos, json.dumps(props)),
|
||||
)
|
||||
max_pos += 1
|
||||
report["rows_created"] += 1
|
||||
conn.commit()
|
||||
return report
|
||||
|
||||
|
||||
def resolve_relations(conn, workspace_id: int | None) -> dict[str, Any]:
|
||||
"""Convert text columns that reference another imported collection's titles
|
||||
into real ``relation`` properties (array of ``collection_pages`` ids)."""
|
||||
collections = conn.execute(
|
||||
"SELECT id, name FROM collections WHERE workspace_id IS ?", (workspace_id,)
|
||||
).fetchall()
|
||||
if not collections:
|
||||
return {"relations_resolved": 0, "details": []}
|
||||
|
||||
titles: dict[int, dict[str, int]] = {}
|
||||
for coll in collections:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE collection_id=?", (coll["id"],)
|
||||
).fetchall()
|
||||
titles[coll["id"]] = {
|
||||
(r["title"] or "").strip(): r["id"]
|
||||
for r in rows if (r["title"] or "").strip()
|
||||
}
|
||||
|
||||
resolved = 0
|
||||
details: list[dict] = []
|
||||
for coll in collections:
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||
(coll["id"],),
|
||||
).fetchall()
|
||||
pages = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(coll["id"],),
|
||||
).fetchall()
|
||||
for prop in props:
|
||||
if prop["prop_type"] not in ("text", "select", "multi_select"):
|
||||
continue
|
||||
key = str(prop["id"])
|
||||
values: list[str] = []
|
||||
for page in pages:
|
||||
pv = json.loads(page["property_values_json"] or "{}")
|
||||
value = pv.get(key)
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
items = value if isinstance(value, list) else [value]
|
||||
values.extend(str(v) for v in items)
|
||||
if not values:
|
||||
continue
|
||||
for target in collections:
|
||||
if target["id"] == coll["id"]:
|
||||
continue
|
||||
target_titles = titles.get(target["id"]) or {}
|
||||
if target_titles and all(v in target_titles for v in values):
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET prop_type='relation', "
|
||||
"related_collection_id=? WHERE id=?",
|
||||
(target["id"], prop["id"]),
|
||||
)
|
||||
for page in pages:
|
||||
pv = json.loads(page["property_values_json"] or "{}")
|
||||
value = pv.get(key)
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
items = value if isinstance(value, list) else [value]
|
||||
pv[key] = [target_titles[str(v)] for v in items if str(v) in target_titles]
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(pv), page["id"]),
|
||||
)
|
||||
resolved += 1
|
||||
details.append({
|
||||
"collection": coll["name"], "property": prop["name"],
|
||||
"related": target["name"],
|
||||
})
|
||||
break
|
||||
conn.commit()
|
||||
return {"relations_resolved": resolved, "details": details}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""FlowDeck — Standard Notes importer (v5.6.0, Phase 4).
|
||||
|
||||
Imports a Standard Notes backup (``.json``): each non-encrypted note becomes a
|
||||
FlowDeck page. Encrypted notes are reported as warnings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_NOTE_TYPES = ("note", "org.standardnotes.sn", "org.standardnotes.plain-text")
|
||||
|
||||
|
||||
def _note_body(content: Any) -> tuple[str, str, bool]:
|
||||
"""Return (title, markdown, encrypted)."""
|
||||
if isinstance(content, dict):
|
||||
if content.get("encrypted"):
|
||||
return "", "", True
|
||||
text = content.get("text") or content.get("preview_plain") or ""
|
||||
title = content.get("title") or ""
|
||||
return title, text, False
|
||||
if isinstance(content, str):
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
if isinstance(parsed, dict) and ("encrypted" in parsed or "000" in parsed):
|
||||
return "", "", True
|
||||
if isinstance(parsed, dict):
|
||||
return parsed.get("title", ""), parsed.get("text", "") or parsed.get("preview_plain", ""), False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return "", content, False
|
||||
return "", "", False
|
||||
|
||||
|
||||
@register_importer
|
||||
class StandardNotesImporter(Importer):
|
||||
source_id = "standard_notes"
|
||||
label = "Standard Notes"
|
||||
description = "Sauvegarde JSON Standard Notes → pages (notes chiffrées ignorées)."
|
||||
extensions = (".json",)
|
||||
order = 39
|
||||
|
||||
def _items(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, dict) and isinstance(obj.get("items"), list):
|
||||
return [x for x in obj["items"] if isinstance(x, dict)]
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
items = self._items(data)
|
||||
if not items:
|
||||
return False
|
||||
return any("content_type" in it for it in items)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
items = self._items(data) or []
|
||||
count = 0
|
||||
for item in items:
|
||||
if item.get("deleted"):
|
||||
continue
|
||||
ctype = str(item.get("content_type", "")).lower()
|
||||
if ctype and not any(t in ctype for t in _NOTE_TYPES):
|
||||
continue
|
||||
title, body, encrypted = _note_body(item.get("content"))
|
||||
if encrypted:
|
||||
result.warn("Note chiffrée ignorée (déchiffrement non pris en charge)")
|
||||
continue
|
||||
if not body.strip():
|
||||
continue
|
||||
if not title:
|
||||
title = next((ln.strip(" #") for ln in body.splitlines() if ln.strip()), "Note")
|
||||
result.pages.append(ImportPage(
|
||||
title=title[:200] or "Note",
|
||||
markdown=body,
|
||||
source_path=item.get("uuid") or filename,
|
||||
external_id=item.get("uuid") or f"{filename}#{count}",
|
||||
))
|
||||
count += 1
|
||||
result.stats["rows"] = count
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""FlowDeck — tabular importers: typed CSV/TSV, Excel, generic JSON (v5.6.0, Phase 2).
|
||||
|
||||
Each source becomes a FlowDeck collection (database): columns are inferred from
|
||||
the data (text/number/date/checkbox/email/url/select/multi_select) and rows are
|
||||
inserted as ``collection_pages``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_TITLE_HEADERS = ("title", "name", "task", "nom", "titre", "subject", "label")
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?")
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
_URL_RE = re.compile(r"^https?://\S+$", re.IGNORECASE)
|
||||
_BOOL_TRUE = {"true", "yes", "oui", "1", "x", "vrai"}
|
||||
_BOOL_FALSE = {"false", "no", "non", "0", "faux", ""}
|
||||
|
||||
|
||||
def _is_number(value: str) -> bool:
|
||||
try:
|
||||
float(str(value).replace(",", ".").replace(" ", ""))
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_bool(value: str) -> bool:
|
||||
return str(value).strip().lower() in _BOOL_TRUE | _BOOL_FALSE
|
||||
|
||||
|
||||
def infer_column_type(values: list[str]) -> str:
|
||||
"""Infer the FlowDeck property type from a list of raw string cells."""
|
||||
sample = [str(v).strip() for v in values if str(v).strip()]
|
||||
if not sample:
|
||||
return "text"
|
||||
if all(_is_bool(v) for v in sample):
|
||||
return "checkbox"
|
||||
if all(_is_number(v) for v in sample):
|
||||
return "number"
|
||||
if all(_DATE_RE.match(v) for v in sample):
|
||||
return "date"
|
||||
if all(_EMAIL_RE.match(v) for v in sample):
|
||||
return "email"
|
||||
if all(_URL_RE.match(v) for v in sample):
|
||||
return "url"
|
||||
unique = {v for v in sample}
|
||||
if len(unique) <= 20 and len(unique) <= max(2, len(sample) // 2):
|
||||
if any(("," in v or ";" in v) for v in sample):
|
||||
return "multi_select"
|
||||
return "select"
|
||||
return "text"
|
||||
|
||||
|
||||
def _split_multi(value: str) -> list[str]:
|
||||
return [p.strip() for p in re.split(r"[;,]", value) if p.strip()]
|
||||
|
||||
|
||||
def coerce_value(prop_type: str, value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if raw == "":
|
||||
return None
|
||||
if prop_type == "number":
|
||||
try:
|
||||
num = float(raw.replace(",", ".").replace(" ", ""))
|
||||
return int(num) if num.is_integer() else num
|
||||
except (ValueError, TypeError):
|
||||
return raw
|
||||
if prop_type == "checkbox":
|
||||
return raw.lower() in _BOOL_TRUE
|
||||
if prop_type == "multi_select":
|
||||
return _split_multi(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def build_schema(headers: list[str], rows: list[dict[str, Any]]) -> tuple[list[dict], str]:
|
||||
"""Return ``(schema, title_header)`` from headers + row dicts."""
|
||||
title_header = ""
|
||||
for h in headers:
|
||||
if h and h.strip().lower() in _TITLE_HEADERS:
|
||||
title_header = h
|
||||
break
|
||||
schema: list[dict] = []
|
||||
for h in headers:
|
||||
if not h or h == title_header:
|
||||
continue
|
||||
ptype = infer_column_type([r.get(h, "") for r in rows])
|
||||
entry: dict[str, Any] = {"name": h, "type": ptype}
|
||||
if ptype in ("select", "status", "multi_select"):
|
||||
seen: list[str] = []
|
||||
for r in rows:
|
||||
vals = _split_multi(str(r.get(h, ""))) if ptype == "multi_select" else [str(r.get(h, "")).strip()]
|
||||
for v in vals:
|
||||
if v and v not in seen:
|
||||
seen.append(v)
|
||||
entry["options"] = [{"name": v, "color": "gray"} for v in seen[:100]]
|
||||
schema.append(entry)
|
||||
if title_header:
|
||||
schema.insert(0, {"name": title_header, "type": "title"})
|
||||
return schema, title_header
|
||||
|
||||
|
||||
def rows_to_collection(name: str, headers: list[str], raw_rows: list[dict[str, Any]]) -> ImportPage:
|
||||
"""Normalize parsed rows into an ImportPage carrying a collection spec."""
|
||||
schema, title_header = build_schema(headers, raw_rows)
|
||||
rows = _normalize_rows(headers, raw_rows, schema, title_header)
|
||||
return ImportPage(
|
||||
title=name or "Imported database",
|
||||
collection={
|
||||
"name": name or "Imported database",
|
||||
"schema": schema,
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
"title_header": title_header,
|
||||
"raw_rows": raw_rows,
|
||||
},
|
||||
source_path=name,
|
||||
external_id=name,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rows(headers: list[str], raw_rows: list[dict[str, Any]],
|
||||
schema: list[dict], title_header: str) -> list[dict[str, Any]]:
|
||||
types = {s["name"]: s["type"] for s in schema}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for raw in raw_rows:
|
||||
title = ""
|
||||
if title_header:
|
||||
title = str(raw.get(title_header, "")).strip()
|
||||
if not title:
|
||||
for h in headers:
|
||||
if h and str(raw.get(h, "")).strip():
|
||||
title = str(raw[h]).strip()
|
||||
break
|
||||
props: dict[str, Any] = {}
|
||||
for h in headers:
|
||||
if not h or h == title_header:
|
||||
continue
|
||||
val = coerce_value(types.get(h, "text"), raw.get(h))
|
||||
if val is not None and val != "":
|
||||
props[h] = val
|
||||
rows.append({"title": title or "Untitled", "properties": props})
|
||||
return rows
|
||||
|
||||
|
||||
def apply_type_mapping(spec: dict, mapping: dict[str, str]) -> dict:
|
||||
"""Override inferred column types (UI mapping) and re-coerce the rows."""
|
||||
if not mapping:
|
||||
return spec
|
||||
for entry in spec.get("schema", []):
|
||||
if entry.get("name") in mapping:
|
||||
entry["type"] = mapping[entry["name"]]
|
||||
headers = spec.get("headers")
|
||||
raw_rows = spec.get("raw_rows")
|
||||
if headers is not None and raw_rows is not None:
|
||||
spec["rows"] = _normalize_rows(headers, raw_rows, spec.get("schema", []),
|
||||
spec.get("title_header", ""))
|
||||
return spec
|
||||
|
||||
|
||||
def _sniff_delimiter(sample: str) -> str:
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
|
||||
except csv.Error:
|
||||
return "\t" if sample.count("\t") > sample.count(",") else ","
|
||||
|
||||
|
||||
@register_importer
|
||||
class CsvImporter(Importer):
|
||||
source_id = "csv"
|
||||
label = "CSV / TSV (typé)"
|
||||
description = "Tableur CSV/TSV : types inférés automatiquement, une collection par fichier."
|
||||
extensions = (".csv", ".tsv")
|
||||
order = 60
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith((".csv", ".tsv"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
if not text.strip():
|
||||
result.warn("Fichier vide")
|
||||
return result.finalize()
|
||||
delimiter = "\t" if filename.lower().endswith(".tsv") else _sniff_delimiter(text[:4096])
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
|
||||
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
||||
rows = [dict(r) for r in reader]
|
||||
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
result.pages.append(rows_to_collection(name, headers, rows))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ExcelImporter(Importer):
|
||||
source_id = "excel"
|
||||
label = "Excel (.xlsx)"
|
||||
description = "Classeur Excel : une collection par feuille (openpyxl)."
|
||||
extensions = (".xlsx", ".xlsm")
|
||||
order = 61
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith((".xlsx", ".xlsm")):
|
||||
return True
|
||||
return low.endswith(".xls")
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
result.warn("openpyxl n'est pas installé : import Excel indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Classeur illisible : {exc}")
|
||||
return result.finalize()
|
||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
total_rows = 0
|
||||
for ws in wb.worksheets:
|
||||
values = list(ws.iter_rows(values_only=True))
|
||||
if not values:
|
||||
continue
|
||||
headers = [str(h).strip() if h is not None else f"Column {i + 1}" for i, h in enumerate(values[0])]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in values[1:]:
|
||||
if row is None or all(c is None or str(c).strip() == "" for c in row):
|
||||
continue
|
||||
rows.append({headers[i]: row[i] for i in range(min(len(headers), len(row)))})
|
||||
if not rows:
|
||||
continue
|
||||
name = f"{base} — {ws.title}" if len(wb.worksheets) > 1 else (base or ws.title)
|
||||
page = rows_to_collection(name, headers, rows)
|
||||
page.source_path = f"{filename}#{ws.title}"
|
||||
page.external_id = page.source_path
|
||||
result.pages.append(page)
|
||||
total_rows += len(rows)
|
||||
result.stats["rows"] = total_rows
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class JsonImporter(Importer):
|
||||
source_id = "json"
|
||||
label = "JSON (mapping générique)"
|
||||
description = "Tableau d'objets JSON → collection (union des clés)."
|
||||
extensions = (".json",)
|
||||
order = 65
|
||||
|
||||
def _records(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||
return value
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
return self._records(data) is not None
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
records = self._records(data)
|
||||
if not records:
|
||||
result.warn("Aucun tableau d'objets JSON détecté")
|
||||
return result.finalize()
|
||||
headers: list[str] = []
|
||||
for rec in records:
|
||||
for key in rec:
|
||||
if key not in headers:
|
||||
headers.append(key)
|
||||
flat: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
row = {}
|
||||
for h in headers:
|
||||
v = rec.get(h)
|
||||
row[h] = json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v
|
||||
flat.append(row)
|
||||
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
result.pages.append(rows_to_collection(name, headers, flat))
|
||||
result.stats["rows"] = len(flat)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""FlowDeck — URL / web clipper importer (v5.6.0, Phase 5).
|
||||
|
||||
Fetches a web page and turns it into a page: a bookmark card (OG metadata)
|
||||
followed by the article converted to FlowDeck blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
|
||||
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain"}
|
||||
_MAX_BYTES = 3_000_000
|
||||
|
||||
|
||||
def _is_public_host(host: str) -> bool:
|
||||
"""SSRF guard: reject loopback/private/link-local/reserved addresses."""
|
||||
if not host or host.lower() in _BLOCKED_HOSTS:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return False
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Seules les URLs http(s) sont autorisées")
|
||||
if not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise ValueError("Hôte non autorisé")
|
||||
return url.strip()
|
||||
|
||||
|
||||
def _bookmark_block(url: str, meta: dict) -> dict:
|
||||
block = {"type": "bookmark", "url": url}
|
||||
for key in ("title", "description", "image", "site_name"):
|
||||
if meta.get(key):
|
||||
block[key] = meta[key]
|
||||
return block
|
||||
|
||||
|
||||
async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None = None) -> ImportResult:
|
||||
"""Fetch ``url`` and build a single-page ImportResult (raises on bad URL)."""
|
||||
safe_url = _validate_url(url)
|
||||
result = ImportResult(source="url")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=15, follow_redirects=True, transport=transport,
|
||||
headers={"User-Agent": "FlowDeck-Importer/1.0"},
|
||||
) as client:
|
||||
response = await client.get(safe_url)
|
||||
response.raise_for_status()
|
||||
if response.url.host and not _is_public_host(response.url.host):
|
||||
raise ValueError("Redirection vers un hôte non autorisé")
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "html" not in content_type.lower():
|
||||
raise ValueError("La ressource n'est pas une page HTML")
|
||||
body = response.text[:_MAX_BYTES]
|
||||
except httpx.HTTPError as exc:
|
||||
result.warn(f"Échec du téléchargement : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
meta = parse_og(body, safe_url)
|
||||
title = (meta.get("title") or urlparse(safe_url).hostname or "Page").strip()
|
||||
markdown = _html_to_markdown(body)
|
||||
blocks = [_bookmark_block(safe_url, meta)]
|
||||
if markdown:
|
||||
blocks.extend(markdown_to_blocks(markdown))
|
||||
result.pages.append(ImportPage(
|
||||
title=title[:200],
|
||||
blocks=blocks,
|
||||
source_path=safe_url,
|
||||
external_id=safe_url,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""FlowDeck — LLM client abstraction (v4.10.0).
|
||||
|
||||
Abstraction over multiple LLM providers so the agent never talks to the DB
|
||||
directly — it emits *tool intentions* (function calls) that AgentEngine turns
|
||||
into guarded internal actions.
|
||||
|
||||
Supported providers (OpenAI-compatible chat-completions JSON response):
|
||||
openai, anthropic, mistral, cohere, google, groq, deepseek, openrouter,
|
||||
nvidia, together, perplexity, xai, qwencloud, minimax, morph, fireworks,
|
||||
cerebras, sambanova, chutes, xiaomi, sealion, sensenova,
|
||||
ollama (local, no key). Anthropic, Google (`/v1beta/openai`) and Cohere
|
||||
(`/compatibility/v1`) expose an OpenAI-compatible surface at their base URL.
|
||||
|
||||
When no API key is configured (or provider == "offline") the client falls back
|
||||
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
|
||||
functional — and fully testable — with zero external calls, which is what the
|
||||
local deployment and the test-suite rely on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Provider → default model + base URL when llm_model/api_base are empty.
|
||||
# All entries speak the OpenAI-compatible chat-completions protocol (Anthropic,
|
||||
# Google and Cohere expose an OpenAI-compatible surface at their given base).
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
||||
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
"perplexity": ("https://api.perplexity.ai", "sonar-pro"),
|
||||
"xai": ("https://api.x.ai/v1", "grok-4.6"),
|
||||
"qwencloud": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"minimax": ("https://api.minimax.chat/v1", "MiniMax-Text-01"),
|
||||
"morph": ("https://api.morphllm.com/v1", "morph-v3-large"),
|
||||
"fireworks": ("https://api.fireworks.ai/inference/v1",
|
||||
"accounts/fireworks/models/deepseek-v4-pro-0813"),
|
||||
"cerebras": ("https://api.cerebras.ai/v1", "llama-3.3-70b"),
|
||||
"sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
|
||||
"chutes": ("https://llm.chutes.ai/v1", "deepseek-ai/DeepSeek-V3"),
|
||||
"xiaomi": ("https://api.xiaomimimo.com/v1", "mimo-7b-rl"),
|
||||
"sealion": ("https://api.sea-lion.ai/v1", "aisingapore/Llama-SEA-LION-v3-70B-IT"),
|
||||
"sensenova": ("https://api.sensenova.cn/compatible-mode/v1", "SenseChat-5"),
|
||||
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
||||
"offline": (None, None),
|
||||
}
|
||||
|
||||
# Friendly display names for the Settings / Agent UIs.
|
||||
PROVIDER_LABELS: dict[str, str] = {
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"mistral": "Mistral",
|
||||
"cohere": "Cohere",
|
||||
"google": "Google Gemini",
|
||||
"groq": "Groq",
|
||||
"deepseek": "DeepSeek",
|
||||
"openrouter": "OpenRouter",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"together": "Together AI",
|
||||
"perplexity": "Perplexity",
|
||||
"xai": "xAI (Grok)",
|
||||
"qwencloud": "DashScope (Alibaba)",
|
||||
"minimax": "MiniMax",
|
||||
"morph": "Morph",
|
||||
"fireworks": "Fireworks AI",
|
||||
"cerebras": "Cerebras",
|
||||
"sambanova": "SambaNova",
|
||||
"chutes": "Chutes AI",
|
||||
"xiaomi": "Xiaomi (MiMo)",
|
||||
"sealion": "SEA-LION",
|
||||
"sensenova": "SenseNova",
|
||||
"ollama": "Ollama (local)",
|
||||
"offline": "Hors-ligne (mock)",
|
||||
}
|
||||
|
||||
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
|
||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
||||
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
||||
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
||||
"mixtral-8x7b-32768", "gemma2-9b-it"],
|
||||
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
|
||||
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
||||
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
|
||||
"together": ["meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
|
||||
"Qwen/Qwen2.5-72B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
|
||||
"perplexity": ["sonar-pro", "sonar", "sonar-reasoning", "sonar-deep-research"],
|
||||
"xai": ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning",
|
||||
"grok-build-0.1"],
|
||||
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||
"minimax": ["MiniMax-Text-01", "abab6.5s-chat", "abab6.5-chat"],
|
||||
"morph": ["morph-v3-large", "morph-v3-fast"],
|
||||
"fireworks": ["accounts/fireworks/models/deepseek-v4-pro-0813",
|
||||
"accounts/fireworks/models/kimi-k2p6",
|
||||
"accounts/fireworks/models/glm-5p2",
|
||||
"accounts/fireworks/models/minimax-m3",
|
||||
"accounts/fireworks/models/gpt-oss-120b",
|
||||
"accounts/fireworks/models/qwen3-8b"],
|
||||
"cerebras": ["llama-3.3-70b", "llama3.1-8b", "llama-3.1-70b"],
|
||||
"sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-405B-Instruct",
|
||||
"Qwen2.5-72B-Instruct"],
|
||||
"chutes": ["deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1",
|
||||
"Qwen/Qwen2.5-72B-Instruct"],
|
||||
"xiaomi": ["mimo-7b-rl", "mimo-7b"],
|
||||
"sealion": ["aisingapore/Llama-SEA-LION-v3-70B-IT",
|
||||
"aisingapore/Gemma-SEA-LION-v3-9B-IT"],
|
||||
"sensenova": ["SenseChat-5", "SenseChat-5-Cantonese", "SenseChat-Turbo"],
|
||||
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
|
||||
"offline": [],
|
||||
}
|
||||
|
||||
# Llama-style / ChatML tool markers used by the mock planner.
|
||||
_CREATE_PATTERNS = [
|
||||
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_collection", {"name": m.group(1).strip()})),
|
||||
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("create_page", {"title": m.group(1).strip()})),
|
||||
]
|
||||
_SEARCH_PATTERNS = [
|
||||
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
|
||||
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
|
||||
]
|
||||
|
||||
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
|
||||
# "créer la collection X", "create collection X", etc.)
|
||||
_COLLECTION_LINE = re.compile(
|
||||
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Normalized completion: either a final text or one or more tool calls."""
|
||||
text: str = ""
|
||||
tool_calls: list[dict] = field(default_factory=list)
|
||||
model: str = ""
|
||||
usage: dict = field(default_factory=dict)
|
||||
notice: str = ""
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Multi-provider chat client with tool-calling support and offline mock."""
|
||||
|
||||
def __init__(self, provider: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
from .llm_config import get_llm_config # local import avoids a cycle
|
||||
|
||||
cfg = get_llm_config()
|
||||
self.provider = (provider or cfg["provider"] or "offline").lower()
|
||||
self.api_key = api_key if api_key is not None else cfg["api_key"]
|
||||
self.api_base = api_base if api_base is not None else cfg["api_base"]
|
||||
base, model = PROVIDERS.get(self.provider, (None, None))
|
||||
self.api_base = self.api_base or base
|
||||
# Le modèle global configuré n'est valable que pour le provider global :
|
||||
# tester un autre provider (ex. nvidia alors que deepseek est actif) ne doit
|
||||
# PAS lui envoyer le modèle du provider actif (sinon « model not found »).
|
||||
cfg_provider = (cfg.get("provider") or "offline").lower()
|
||||
global_model = (cfg.get("model") or "") if self.provider == cfg_provider else ""
|
||||
self.default_model = global_model or model or "gpt-4o"
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def complete(self, messages: list[dict], *, model: str | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
stream: bool = False) -> LLMResponse:
|
||||
"""Send a chat completion. Returns text and/or tool_calls."""
|
||||
model = model or self.default_model
|
||||
|
||||
if self.provider == "offline" or not self._has_credentials():
|
||||
return await self._mock_complete(messages, model, tools)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._http_complete(messages, model, tools),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
|
||||
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
|
||||
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
|
||||
# l'utilisateur voie pourquoi rien n'a été généré.
|
||||
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
|
||||
raise
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""True when a real provider is configured."""
|
||||
return self.provider != "offline" and self._has_credentials()
|
||||
|
||||
async def ping(self, *, model: str | None = None) -> LLMResponse:
|
||||
"""Reach the provider without mock fallback (used by the "Test connection"
|
||||
UI). Raises on any real error so the caller can surface it."""
|
||||
model = model or self.default_model
|
||||
if self.provider == "offline":
|
||||
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
|
||||
if not self._has_credentials():
|
||||
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
|
||||
return await asyncio.wait_for(
|
||||
self._http_complete(
|
||||
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
|
||||
model,
|
||||
None,
|
||||
),
|
||||
timeout=settings.agent_run_timeout_seconds,
|
||||
)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _has_credentials(self) -> bool:
|
||||
if self.provider == "ollama":
|
||||
return True # local, no key required
|
||||
return bool(self.api_key)
|
||||
|
||||
def _endpoint(self) -> str:
|
||||
return f"{self.api_base.rstrip('/')}/chat/completions"
|
||||
|
||||
@staticmethod
|
||||
def _http_error_detail(exc: httpx.HTTPStatusError) -> str:
|
||||
"""Human-readable HTTP error including the provider's response body.
|
||||
|
||||
Providers return actionable JSON on 4xx (e.g. « model not allowed »,
|
||||
« country not supported »); surfacing it makes the Settings test
|
||||
debuggable instead of a bare « 403 Forbidden ».
|
||||
"""
|
||||
resp = exc.response
|
||||
try:
|
||||
body = (resp.text or "").strip()
|
||||
except Exception: # noqa: BLE001 — body already consumed / undecodable
|
||||
body = ""
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "…"
|
||||
base = f"{resp.status_code} {resp.reason_phrase} ({resp.url})"
|
||||
return f"{base}: {body}" if body else base
|
||||
|
||||
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = [{"type": "function", "function": t} for t in tools]
|
||||
payload["tool_choice"] = "auto"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Repli robuste : le modèle choisi a été retiré / n'existe plus
|
||||
# (404 « model not found » / 410 « has reached its end of life »).
|
||||
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
|
||||
# provider et on signale le basculement — la liste validée peut avoir
|
||||
# vieilli (modèle déprécié entre deux rafraîchissements).
|
||||
if exc.response.status_code in (404, 410) \
|
||||
and model and self.default_model and model != self.default_model:
|
||||
logger.warning(
|
||||
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
|
||||
model, exc.response.status_code, self.provider, self.default_model,
|
||||
)
|
||||
return await self._http_complete(messages, self.default_model, tools, _noticer=(
|
||||
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
|
||||
f"Réponse générée avec « {self.default_model} » à la place."
|
||||
))
|
||||
# Surface the provider's own error body (403 « forbidden », 400 …).
|
||||
raise RuntimeError(self._http_error_detail(exc)) from exc
|
||||
|
||||
response = self._parse_response(data, model)
|
||||
response.notice = _noticer or ""
|
||||
return response
|
||||
|
||||
def _parse_response(self, data: dict, model: str) -> LLMResponse:
|
||||
choice = data["choices"][0]["message"]
|
||||
text = choice.get("content") or ""
|
||||
tool_calls = []
|
||||
for tc in choice.get("tool_calls") or []:
|
||||
fn = tc.get("function") or {}
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
tool_calls.append({
|
||||
"id": tc.get("id") or "",
|
||||
"name": fn.get("name"),
|
||||
"arguments": args,
|
||||
"arguments_raw": fn.get("arguments") or "",
|
||||
})
|
||||
return LLMResponse(
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
model=model,
|
||||
usage=data.get("usage", {}),
|
||||
)
|
||||
|
||||
# ── Offline mock planner (deterministic, no network) ──
|
||||
|
||||
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
||||
user_content = self._last_user_content(messages)
|
||||
sys_content = self._system_content(messages)
|
||||
# Only the user's objective drives the planner. The engine appends the
|
||||
# workspace/document snapshot under "# Contexte"; that text must never
|
||||
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
|
||||
# used to misroute content requests into tool calls).
|
||||
objective = user_content.split("\n# Contexte")[0]
|
||||
|
||||
# Once tool results are already in the conversation, we have acted:
|
||||
# stop issuing new tool calls and conclude.
|
||||
if any(m.get("role") == "tool" for m in messages):
|
||||
return LLMResponse(
|
||||
text="Objectif traité — actions enregistrées dans le journal d'audit.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Skill-driven: if the objective names a known skill, mirror its template.
|
||||
skill_hint = self._extract_skill_hint(objective)
|
||||
|
||||
if skill_hint == "sprint":
|
||||
return LLMResponse(
|
||||
tool_calls=[
|
||||
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
|
||||
{"name": "create_collection", "arguments": {"name": "Sprint"}},
|
||||
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
|
||||
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
|
||||
],
|
||||
text="Plan: analyze open issues, then build a sprint board.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Inline content-generation ("Ask AI" / "AI meeting note") — answered
|
||||
# before the tool-intent heuristics and scoped to the user objective
|
||||
# only, so an injected "# Contexte" that happens to mention "collection"
|
||||
# can't misroute a writing request into a create-collection action.
|
||||
draft = self._draft_reply(objective)
|
||||
if draft:
|
||||
return LLMResponse(text=draft, model=model)
|
||||
|
||||
# Documents / espaces de travail (offline): unambiguous intents resolved
|
||||
# from the objective — create a document (optionally in a named
|
||||
# workspace) or list the accessible workspaces.
|
||||
doc_args = self._document_create_args(objective)
|
||||
if doc_args is not None:
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_document", "arguments": doc_args}],
|
||||
text="Plan: création d'un document.",
|
||||
model=model,
|
||||
)
|
||||
if self._is_workspaces_request(objective):
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
|
||||
text="Plan: lister les espaces de travail.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Exact keyword → tool intent resolution (objective only).
|
||||
for regex, builder in _CREATE_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
||||
text=f"Plan: running {builder(m)[0]}.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
for regex, builder in _SEARCH_PATTERNS:
|
||||
m = regex.search(objective)
|
||||
if m:
|
||||
return LLMResponse(
|
||||
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
||||
text=f"Plan: searching '{m.group(1)}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Loose "collection <X>" detection → treat as a create intent.
|
||||
low = objective.lower()
|
||||
if "collection" in low:
|
||||
m = _COLLECTION_LINE.search(objective)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
return LLMResponse(
|
||||
tool_calls=[{"name": "create_collection",
|
||||
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
|
||||
text=f"Plan: create collection '{name}'.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Plain conversational objective → final answer (no tool).
|
||||
return LLMResponse(
|
||||
text=self._summarize(objective),
|
||||
model=model,
|
||||
)
|
||||
|
||||
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
|
||||
"""Inject a collection id from the context when the planner left it as 0."""
|
||||
args = dict(args)
|
||||
if args.get("collection_id") == 0:
|
||||
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
|
||||
if match:
|
||||
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
|
||||
if ids:
|
||||
args["collection_id"] = ids[0]
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _document_create_args(content: str) -> dict | None:
|
||||
"""Deterministic `create_document` intent for the offline mock.
|
||||
|
||||
Only fires when the user clearly asks to *create* a document (a content
|
||||
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
|
||||
Returns None when the message is not a create-document intent.
|
||||
"""
|
||||
low = content.lower()
|
||||
if "document" not in low:
|
||||
return None
|
||||
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
|
||||
"nouveau document", "nouvelle page", "faire un")):
|
||||
return None
|
||||
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
|
||||
title = quotes[0].strip() if quotes else None
|
||||
if not title:
|
||||
m = re.search(
|
||||
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
|
||||
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
|
||||
content, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
if not title:
|
||||
return None
|
||||
args = {"title": title}
|
||||
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
|
||||
args["workspace_name"] = quotes[-1].strip()
|
||||
return args
|
||||
|
||||
@staticmethod
|
||||
def _is_workspaces_request(content: str) -> bool:
|
||||
"""True when the user asks to list / locate the workspaces."""
|
||||
low = content.lower()
|
||||
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
|
||||
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
|
||||
"affiche", "mes espaces", "ou sont", "où sont"))
|
||||
return has_ws and has_verb
|
||||
|
||||
@staticmethod
|
||||
def _system_content(messages) -> str:
|
||||
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
|
||||
|
||||
@staticmethod
|
||||
def _last_user_content(messages) -> str:
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
c = m.get("content", "")
|
||||
if isinstance(c, list):
|
||||
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
|
||||
return str(c)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_skill_hint(content: str) -> str | None:
|
||||
low = content.lower()
|
||||
if "sprint" in low or "préparation de sprint" in low:
|
||||
return "sprint"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _summarize(content: str) -> str:
|
||||
"""Produce a terse final summary from a conversational objective."""
|
||||
content = content.split("\n# Contexte")[0]
|
||||
return (content[:600] + "…" if len(content) > 600 else content)
|
||||
|
||||
def _draft_reply(self, content: str) -> str | None:
|
||||
"""Generate usable structured copy for content/meeting requests when no
|
||||
real LLM is configured (offline mock). Returns None when the message is
|
||||
not a clear content-generation intent so other paths keep their behavior.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
low = content.lower()
|
||||
title = None
|
||||
m = re.search(r"intitul[ée]e\s*[«\"']([^»\"']+)[»\"']", content)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
today = date.today().isoformat()
|
||||
|
||||
# ── AI meeting note template ──
|
||||
if any(k in low for k in ("ai meeting note", "meeting note",
|
||||
"compte-rendu", "compte rendu",
|
||||
"notes de réunion", "réunion")):
|
||||
return (
|
||||
"📅 AI Meeting Note\n"
|
||||
f"Date : {today} · Participants : (à renseigner)\n"
|
||||
"\n"
|
||||
"## Résumé\n"
|
||||
"Point central de la discussion et contexte (à compléter).\n"
|
||||
"\n"
|
||||
"## Décisions\n"
|
||||
"• Décision 1 — valider le périmètre et les responsables.\n"
|
||||
"• Décision 2 — définir la prochaine échéance.\n"
|
||||
"\n"
|
||||
"## Action items\n"
|
||||
"☐ Action 1 — responsable : …, échéance : …\n"
|
||||
"☐ Action 2 — responsable : …, échéance : …\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Planifier le suivi et archiver ce compte-rendu.\n"
|
||||
)
|
||||
|
||||
# ── Traduction / analyse du document (hors-ligne) ──
|
||||
if re.search(r"traduis|traduit|translate", low):
|
||||
return (
|
||||
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
|
||||
"connecté).\n\n"
|
||||
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
|
||||
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
|
||||
"à approuver ou à rejeter avant application."
|
||||
)
|
||||
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
|
||||
return (
|
||||
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
|
||||
"document.\n\n"
|
||||
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
|
||||
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
|
||||
"ou à rejeter avant application."
|
||||
)
|
||||
|
||||
# ── Page / document draft (contextual "Ask AI") ──
|
||||
if title:
|
||||
t = title[:80]
|
||||
return (
|
||||
f"{t}\n"
|
||||
"\n"
|
||||
f"Présentation générale du sujet « {t} » : objectif, contexte et "
|
||||
"public visé en quelques phrases. (Document généré hors-ligne — "
|
||||
"connectez une clé API pour une rédaction complète.)\n"
|
||||
"\n"
|
||||
"## Objectif\n"
|
||||
"• Clarifier le besoin couvert par ce document.\n"
|
||||
"• Lister les livrables attendus.\n"
|
||||
"\n"
|
||||
"## Points clés\n"
|
||||
"• Idée principale 1 avec les arguments associés.\n"
|
||||
"• Idée principale 2 et les exemples concrets.\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Relire, compléter et mettre en forme ce contenu.\n"
|
||||
)
|
||||
|
||||
# ── Generic drafting verb, no title (typing directly in the chat) ──
|
||||
if re.search(r"^(r[ée]dige|[ée]cris|[ée]crire|g[ée]n[èe]re|produis|d[ée]veloppe|"
|
||||
r"[ée]cris\s+un|g[ée]n[èe]re\s+un|r[ée]dige\s+un)\b", low):
|
||||
return (
|
||||
"## Introduction\n"
|
||||
"Contexte et objectif de ce texte, en une à deux phrases.\n"
|
||||
"\n"
|
||||
"## Développement\n"
|
||||
"• Premier argument structuré avec un exemple.\n"
|
||||
"• Deuxième argument appuyé par une donnée ou un fait.\n"
|
||||
"\n"
|
||||
"## Conclusion\n"
|
||||
"Synthèse et prochaine étape recommandée.\n"
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,444 @@
|
||||
"""FlowDeck — Runtime LLM configuration store (v4.10.2).
|
||||
|
||||
Precedence (per conversation):
|
||||
1. explicit `provider`/`model` passed to the run endpoint,
|
||||
2. the user's saved key for that provider (`user_llm_keys`),
|
||||
3. the global `llm_config` row (id=1) — admin UI,
|
||||
4. `settings.llm_*` (.env), default « offline mock ».
|
||||
|
||||
Rows are created lazily, so .env stays the default until saved from the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
# actually serves. The fetched list is validated (name filter + live probe)
|
||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# chat completions — the exact failure the user hit.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
||||
|
||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||
_NON_CHAT_MARKERS = (
|
||||
"embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
|
||||
"transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
|
||||
"stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
|
||||
"ocr", "inpainting", "depth", "motion", "sento-",
|
||||
)
|
||||
|
||||
|
||||
def _is_likely_chat(model_id: str) -> bool:
|
||||
ml = model_id.lower()
|
||||
return not any(m in ml for m in _NON_CHAT_MARKERS)
|
||||
|
||||
__all__ = [
|
||||
"get_llm_config", "set_llm_config", "provider_info",
|
||||
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
|
||||
"delete_user_llm_key", "fetch_provider_models",
|
||||
"mark_llm_config_verified", "mark_user_llm_key_verified",
|
||||
]
|
||||
|
||||
|
||||
def get_llm_config() -> dict:
|
||||
"""Return the effective LLM config — DB overrides .env when present."""
|
||||
cfg = {
|
||||
"provider": settings.llm_provider or "offline",
|
||||
"model": settings.llm_model or "gpt-4o",
|
||||
"api_key": settings.llm_api_key or "",
|
||||
"api_base": settings.llm_api_base or "",
|
||||
"verified": 0,
|
||||
"verified_model": "",
|
||||
"verified_at": "",
|
||||
"last_error": "",
|
||||
}
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT provider, model, api_key, api_base, verified, verified_model, "
|
||||
"verified_at, last_error FROM llm_config WHERE id=1"
|
||||
).fetchone()
|
||||
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
|
||||
return cfg
|
||||
if row:
|
||||
for key in ("provider", "model", "api_key", "api_base"):
|
||||
if row[key]:
|
||||
cfg[key] = row[key]
|
||||
if row["provider"]:
|
||||
cfg["verified"] = row["verified"] or 0
|
||||
cfg["verified_model"] = row["verified_model"] or ""
|
||||
cfg["verified_at"] = row["verified_at"] or ""
|
||||
cfg["last_error"] = row["last_error"] or ""
|
||||
return cfg
|
||||
|
||||
|
||||
def set_llm_config(*, provider: str | None = None, model: str | None = None,
|
||||
api_key: str | None = None, api_base: str | None = None,
|
||||
clear_keys: bool = False) -> dict:
|
||||
"""Upsert the runtime LLM config row (id=1) and return the new effective config.
|
||||
|
||||
An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak
|
||||
the model/base without re-typing the key). Changing the API key resets the
|
||||
`verified` flag — the provider has to pass a connection test again.
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_llm_config()
|
||||
if provider is not None:
|
||||
cfg["provider"] = provider
|
||||
if model is not None:
|
||||
cfg["model"] = model
|
||||
if api_key is not None and api_key.strip():
|
||||
key_changed = cfg.get("api_key") != api_key.strip()
|
||||
cfg["api_key"] = api_key.strip()
|
||||
if key_changed:
|
||||
cfg["verified"] = 0
|
||||
cfg["verified_model"] = ""
|
||||
cfg["last_error"] = ""
|
||||
if api_base is not None:
|
||||
# Normalise so a base equal to the provider default is stored as "use
|
||||
# default" — a later correction of PROVIDERS then applies automatically.
|
||||
cfg["api_base"] = _normalize_api_base(
|
||||
provider or cfg.get("provider") or "offline", api_base
|
||||
)
|
||||
if clear_keys:
|
||||
cfg["api_key"] = ""
|
||||
cfg["api_base"] = ""
|
||||
cfg["verified"] = 0
|
||||
cfg["verified_model"] = ""
|
||||
cfg["last_error"] = ""
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO llm_config (id, provider, model, api_key, api_base,
|
||||
verified, verified_model, last_error, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
provider=excluded.provider, model=excluded.model,
|
||||
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||
verified=excluded.verified, verified_model=excluded.verified_model,
|
||||
last_error=excluded.last_error,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"],
|
||||
cfg.get("verified", 0), cfg.get("verified_model", ""),
|
||||
cfg.get("last_error", "")),
|
||||
)
|
||||
conn.commit()
|
||||
return cfg
|
||||
|
||||
|
||||
def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None:
|
||||
"""Record the outcome of the admin 'Test connection' for the global default."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone()
|
||||
provider = row["provider"] if row else (settings.llm_provider or "offline")
|
||||
conn.execute(
|
||||
"""INSERT INTO llm_config (id, provider, model, verified, verified_model,
|
||||
verified_at, last_error, updated_at)
|
||||
VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
verified=excluded.verified,
|
||||
verified_model=excluded.verified_model,
|
||||
verified_at=excluded.verified_at,
|
||||
last_error=excluded.last_error,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(provider, 1 if ok else 0, model if ok else "",
|
||||
1 if ok else 0, "" if ok else error),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def provider_info() -> list[dict]:
|
||||
"""Providers list for the UI: known models + whether an API key is required.
|
||||
|
||||
``base_url`` is the endpoint the application uses by default for this
|
||||
provider's chat requests (shown in the Settings "URL API" fields).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for name, (base, default_model) in PROVIDERS.items():
|
||||
models = list(PROVIDER_MODELS.get(name) or ())
|
||||
if default_model and default_model not in models:
|
||||
models.insert(0, default_model)
|
||||
out.append({
|
||||
"id": name,
|
||||
"name": PROVIDER_LABELS.get(name) or name.replace("_", " ").title(),
|
||||
"default_model": default_model,
|
||||
"models": models,
|
||||
"base_url": (base or ""),
|
||||
"requires_key": name not in ("offline", "ollama"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Per-user provider keys ──
|
||||
|
||||
|
||||
def _mask_key(row) -> dict:
|
||||
"""Public view of a stored key row: never exposes the raw api_key."""
|
||||
def _get(name, default=""):
|
||||
try:
|
||||
v = row[name]
|
||||
return default if v is None else v
|
||||
except (KeyError, IndexError):
|
||||
return default
|
||||
return {
|
||||
"provider": row["provider"],
|
||||
"api_base": row["api_base"] or "",
|
||||
"default_model": row["default_model"] or "",
|
||||
"models": json.loads(row["models_json"] or "[]"),
|
||||
"has_key": bool(row["api_key"]),
|
||||
"verified": bool(_get("verified", 0)),
|
||||
"verified_model": _get("verified_model") or "",
|
||||
"last_error": _get("last_error") or "",
|
||||
}
|
||||
|
||||
|
||||
def get_user_llm_key(user_id: int, provider: str) -> dict | None:
|
||||
"""Return a stored key row (includes the raw api_key — server-side only)."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_user_llm_keys(user_id: int) -> list[dict]:
|
||||
"""Public (masked) list of the user's saved provider keys."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [_mask_key(r) for r in rows]
|
||||
|
||||
|
||||
def _default_api_base(provider: str) -> str:
|
||||
"""The OpenAI-compatible base URL the app uses by default for a provider."""
|
||||
base = (PROVIDERS.get(provider.lower()) or (None, None))[0]
|
||||
return (base or "").rstrip("/")
|
||||
|
||||
|
||||
def _normalize_api_base(provider: str, api_base: str) -> str:
|
||||
"""Drop an api_base that just repeats the provider default.
|
||||
|
||||
Storing the default as a per-user override freezes it: a later correction
|
||||
of the provider URL (e.g. Cohere `/v2` → `/compatibility/v1`) would never
|
||||
apply. An empty value means "use the provider default".
|
||||
"""
|
||||
value = (api_base or "").strip()
|
||||
if value.rstrip("/") == _default_api_base(provider):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
|
||||
api_base: str | None = None, default_model: str = "",
|
||||
models: list[str] | None = None) -> dict:
|
||||
"""Upsert a user's provider key. Empty api_key keeps the existing one
|
||||
(allows saving model/base without re-typing the key). Saving a *different*
|
||||
key resets the `verified` flag so the provider must pass a test again.
|
||||
|
||||
``api_base`` uses ``None`` to mean "keep the stored value" and an empty
|
||||
string to explicitly reset it to the provider default.
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
existing = get_user_llm_key(user_id, provider)
|
||||
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
|
||||
if api_base is None:
|
||||
new_base = (existing.get("api_base", "") if existing else "")
|
||||
else:
|
||||
new_base = _normalize_api_base(provider, api_base)
|
||||
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
|
||||
new_models = models if models is not None else (
|
||||
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
|
||||
)
|
||||
key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key)
|
||||
# A changed key invalidates the previous verification; keep it otherwise.
|
||||
verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, provider) DO UPDATE SET
|
||||
api_key=excluded.api_key, api_base=excluded.api_base,
|
||||
default_model=excluded.default_model, models_json=excluded.models_json,
|
||||
verified=excluded.verified,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(user_id, provider, new_key, new_base, new_model,
|
||||
json.dumps(new_models, ensure_ascii=False), verified),
|
||||
)
|
||||
conn.commit()
|
||||
return get_user_llm_key(user_id, provider) or {
|
||||
"provider": provider, "api_key": new_key, "api_base": new_base,
|
||||
"default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False),
|
||||
"verified": verified,
|
||||
}
|
||||
|
||||
|
||||
def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *,
|
||||
model: str = "", error: str = "") -> None:
|
||||
"""Record the outcome of a connection test on one of the user's providers."""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE user_llm_keys
|
||||
SET verified=?, verified_model=?, last_error=?,
|
||||
verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE user_id=? AND provider=?""",
|
||||
(1 if ok else 0, model if ok else "", "" if ok else error,
|
||||
1 if ok else 0, user_id, provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_user_llm_key(user_id: int, provider: str) -> None:
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_llm_keys WHERE user_id=? AND provider=?",
|
||||
(user_id, provider),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
api_base: str = "", timeout: int = 20) -> list[str]:
|
||||
"""Fetch the live model list from a provider (best-effort, no mock).
|
||||
|
||||
OpenAI-compatible providers (including Google's `/openai` surface and
|
||||
Cohere's compatibility API) use `GET {base}/models` with a Bearer token;
|
||||
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
provider = provider.lower()
|
||||
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
|
||||
if not base:
|
||||
return [] # offline — nothing to fetch
|
||||
|
||||
base_url = base.rstrip("/")
|
||||
headers: dict = {}
|
||||
if provider == "anthropic":
|
||||
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
body = (resp.text or "").strip()
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "…"
|
||||
raise RuntimeError(
|
||||
f"{resp.status_code} {resp.reason_phrase} ({resp.url}): {body}"
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
ids: list[str] = []
|
||||
for item in data.get("data") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
i = (item.get("id") or "").strip()
|
||||
if i:
|
||||
ids.append(i)
|
||||
for item in data.get("models") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
n = (item.get("name") or item.get("id") or "").strip()
|
||||
if provider == "google" and n.startswith("models/"):
|
||||
n = n[len("models/"):]
|
||||
if n:
|
||||
ids.append(n)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for i in ids:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
|
||||
# Providers like NVIDIA list their whole catalog, most of which is NOT served
|
||||
# by /v1/chat/completions (404 sur « model not found »). Filter by name first,
|
||||
# then probe the survivors with a minimal chat call so only usable models stay.
|
||||
if provider in _CHAT_VALIDATED_PROVIDERS and out:
|
||||
candidates = [m for m in out if _is_likely_chat(m)] or out
|
||||
validated = await _validate_chat_models(base_url, api_key, candidates)
|
||||
# Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
|
||||
# indisponibilité passagère) on garde la liste filtrée par nom.
|
||||
out = validated if validated else candidates
|
||||
|
||||
return out[:300]
|
||||
|
||||
|
||||
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
|
||||
*, timeout: float = 12.0, concurrency: int = 5,
|
||||
deadline: float = 90.0, attempts: int = 2) -> list[str]:
|
||||
"""Probe `POST {base_url}/chat/completions` for each candidate and keep only
|
||||
the models that genuinely answer — using the exact payload the app sends at
|
||||
runtime (`temperature: 0.2`, no `max_tokens`).
|
||||
|
||||
Hard failures (404 model inconnu, 410 modèle retiré…) exclude the model.
|
||||
A 429 (rate limit) is kept: it proves the route exists, so the model is
|
||||
usable once the quota frees up. Transient failures (timeouts, 5xx, network)
|
||||
are retried once before giving up, so slow-but-working models survive.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
start = time.monotonic()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||
payload_tpl = {
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
async def probe(model: str) -> str | None:
|
||||
if time.monotonic() - start > deadline:
|
||||
return None
|
||||
payload: dict = {"model": model, **payload_tpl}
|
||||
for attempt in range(attempts):
|
||||
if time.monotonic() - start > deadline:
|
||||
return None
|
||||
try:
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
code = resp.status_code
|
||||
if code < 300 or code == 429:
|
||||
return model
|
||||
if code < 500: # 400/401/403/404/410… → définitivement non utilisable
|
||||
return None
|
||||
# 5xx → passage passager, on retente une fois
|
||||
except Exception: # noqa: BLE001 — timeouts / connexion → on retente
|
||||
if attempt >= attempts - 1:
|
||||
return None
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*(probe(m) for m in candidates))
|
||||
return [m for m in results if isinstance(m, str)]
|
||||
@@ -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,213 @@
|
||||
"""FlowDeck — Notification service (v4.9.0 collaboration).
|
||||
|
||||
Creates in-app notifications (mentions, comments, page changes) and triggers
|
||||
email delivery via :mod:`app.services.mailer` when the target user has opted in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import mailer
|
||||
|
||||
|
||||
def create_notification(
|
||||
user_id: int,
|
||||
actor_id: int | None,
|
||||
ntype: str,
|
||||
title: str,
|
||||
message: str,
|
||||
resource_type: str = "page",
|
||||
resource_id: int = 0,
|
||||
url: str = "",
|
||||
conn=None,
|
||||
commit: bool = True,
|
||||
) -> int | None:
|
||||
"""Insert a notification row. Returns the new id (or None if skipped).
|
||||
|
||||
``conn`` may be supplied to join an existing transaction (caller controls
|
||||
commit). Otherwise a dedicated connection is opened and committed.
|
||||
"""
|
||||
if not user_id:
|
||||
return None
|
||||
if conn is not None:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO notifications
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO notifications
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
|
||||
|
||||
|
||||
def extract_mentions(text: str) -> list[str]:
|
||||
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
|
||||
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
|
||||
|
||||
|
||||
def process_mentions(
|
||||
text: str,
|
||||
actor_id: int,
|
||||
ntype: str,
|
||||
title: str,
|
||||
message: str,
|
||||
resource_type: str = "page",
|
||||
resource_id: int = 0,
|
||||
url: str = "",
|
||||
conn=None,
|
||||
) -> list[int]:
|
||||
"""Create notifications for every user @-mentioned in ``text``.
|
||||
|
||||
Returns the list of mentioned user ids that were notified.
|
||||
"""
|
||||
handles = extract_mentions(text)
|
||||
if not handles:
|
||||
return []
|
||||
notified = []
|
||||
if conn is not None:
|
||||
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified)
|
||||
return notified
|
||||
with get_conn() as conn:
|
||||
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified)
|
||||
conn.commit()
|
||||
return notified
|
||||
|
||||
|
||||
def _do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified):
|
||||
placeholders = ",".join("?" * len(handles))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
|
||||
handles,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if row["id"] == actor_id:
|
||||
continue
|
||||
create_notification(
|
||||
row["id"], actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, conn=conn, commit=False,
|
||||
)
|
||||
notified.append(row["id"])
|
||||
mailer.notify_user(
|
||||
row["id"], subject=title, body_text=message, cta_url=url or "",
|
||||
)
|
||||
|
||||
|
||||
def get_user_prefs(user_id: int, conn=None) -> dict:
|
||||
if conn is not None:
|
||||
row = conn.execute(
|
||||
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row or not row["notification_prefs"]:
|
||||
return {"comments": True, "mentions": True, "reminders": True, "assignments": True}
|
||||
try:
|
||||
prefs = json.loads(row["notification_prefs"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
prefs = {}
|
||||
return {"comments": bool(prefs.get("comments", True)),
|
||||
"mentions": bool(prefs.get("mentions", True)),
|
||||
"reminders": bool(prefs.get("reminders", True)),
|
||||
"assignments": bool(prefs.get("assignments", True))}
|
||||
|
||||
|
||||
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
|
||||
if conn is not None:
|
||||
conn.execute(
|
||||
"UPDATE users SET notification_prefs=? WHERE id=?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET notification_prefs=? WHERE id=?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return prefs
|
||||
|
||||
|
||||
def _person_ids(value) -> list[int]:
|
||||
"""Extract user ids from a stored ``person`` property value."""
|
||||
ids: list[int] = []
|
||||
if isinstance(value, dict):
|
||||
value = [value]
|
||||
if isinstance(value, list):
|
||||
for person in value:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
try:
|
||||
ids.append(int(person["id"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def notify_assignment(collection_id: int, page_id: int, page_title: str,
|
||||
old_props: dict, new_props: dict, actor_id: int | None,
|
||||
conn=None) -> list[int]:
|
||||
"""Notify users newly assigned via a ``person`` property.
|
||||
|
||||
Compares old vs new property values keyed by property id; users present
|
||||
in the new value but not the old one get an in-app + (opt-in) email
|
||||
notification. Returns the notified user ids.
|
||||
"""
|
||||
def _run(c):
|
||||
props = c.execute(
|
||||
"SELECT id, name FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='person'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
newly: list[int] = []
|
||||
for p in props:
|
||||
key = str(p["id"])
|
||||
before = set(_person_ids((old_props or {}).get(key)))
|
||||
after = _person_ids((new_props or {}).get(key))
|
||||
for uid in after:
|
||||
if uid not in before and uid != actor_id and uid not in newly:
|
||||
newly.append(uid)
|
||||
for uid in newly:
|
||||
create_notification(
|
||||
uid, actor_id, "assignment",
|
||||
title="Assigned to you",
|
||||
message=page_title or "Untitled",
|
||||
resource_type="db_page",
|
||||
resource_id=page_id,
|
||||
url=f"/collections/{collection_id}",
|
||||
conn=c, commit=False,
|
||||
)
|
||||
mailer.notify_user(
|
||||
uid, subject="[FlowDeck] Assigned to you",
|
||||
body_text=page_title or "Untitled",
|
||||
cta_url=f"/collections/{collection_id}",
|
||||
prefs_key="assignments",
|
||||
)
|
||||
return newly
|
||||
|
||||
if conn is not None:
|
||||
return _run(conn)
|
||||
with get_conn() as c:
|
||||
result = _run(c)
|
||||
c.commit()
|
||||
return result
|
||||
@@ -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,452 @@
|
||||
"""FlowDeck — Permission manager: workspace roles + granular ACL (v6.0.0).
|
||||
|
||||
Two layers:
|
||||
|
||||
1. **Workspace roles** (v4.10.0, agent guard): every user has a single role in
|
||||
each workspace (owner > owner-membership > editor > commenter > viewer).
|
||||
The FlowDeck Agent always acts with *at most* the permissions of the
|
||||
invoking user (Notion Agent principle).
|
||||
|
||||
2. **Granular permissions** (v6.0.0): explicit page / collection / property
|
||||
grants plus reusable user groups. Resolution follows the least-privilege
|
||||
rule — an explicit grant on a resource overrides the inherited chain
|
||||
(page → collection → workspace), while ``restricted`` / ``private``
|
||||
resources deny access unless a grant (or the workspace owner / admin)
|
||||
applies.
|
||||
|
||||
Resolution results are cached for 60 s to keep the hot paths (sidebar, view
|
||||
rendering, route guards) < 10 ms per check; ``PermissionManager.invalidate()``
|
||||
drops the cache after any grant/revoke/type change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
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"}
|
||||
|
||||
# Granular resource roles (ranked, least → most privileged).
|
||||
_GRANULAR_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
_ROLE_RANK = {role: i for i, role in enumerate(_GRANULAR_ROLES)}
|
||||
_PROPERTY_ROLES = ("viewer", "editor")
|
||||
_PROPERTY_RANK = {"viewer": 0, "editor": 1}
|
||||
|
||||
# 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 + granular ACL checks."""
|
||||
|
||||
def __init__(self, user_id: int, is_admin: bool = False):
|
||||
self.user_id = user_id
|
||||
self._is_admin_override = bool(is_admin)
|
||||
self._cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
# ── Cache helpers ──
|
||||
|
||||
def _cached(self, key: str, ttl: float, fn):
|
||||
now = time.monotonic()
|
||||
hit = self._cache.get(key)
|
||||
if hit and now - hit[0] < ttl:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
self._cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop the resolution cache after a grant/revoke/type change."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── 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")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Granular permissions (v6.0.0)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _is_admin(self, conn) -> bool:
|
||||
if self._is_admin_override:
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
def _owns_workspace(self, conn, workspace_id: int | None) -> bool:
|
||||
if workspace_id is None:
|
||||
# No workspace → single-user semantics: the actor is the owner.
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
def user_group_ids(self, conn) -> list[int]:
|
||||
return [
|
||||
r["group_id"]
|
||||
for r in conn.execute(
|
||||
"SELECT group_id FROM group_members WHERE user_id=?", (self.user_id,)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def _explicit_grant_role(self, conn, table: str, fk: str, resource_id: int,
|
||||
role_rank: dict[str, int] | None = None) -> str | None:
|
||||
"""Most-privileged explicit role on ``table`` for the user / groups."""
|
||||
rank = role_rank or _ROLE_RANK
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(resource_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? AND user_id=?",
|
||||
(resource_id, self.user_id),
|
||||
).fetchall()
|
||||
best = max((rank.get(r["role"], -1) for r in rows), default=-1)
|
||||
if best < 0:
|
||||
return None
|
||||
rev = {rank[k]: k for k in rank}
|
||||
return rev[best]
|
||||
|
||||
# ── Page-level ──
|
||||
|
||||
def get_page_permission(self, page_id: int) -> str | None:
|
||||
"""Effective page role for ``self.user_id`` (least privilege).
|
||||
|
||||
Chain: explicit page grant > explicit collection grant > workspace
|
||||
role. ``restricted`` / ``private`` pages ignore the inherited chain.
|
||||
Returns ``None`` when the user must not see the page at all.
|
||||
"""
|
||||
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT permission_type, workspace_id, collection_id FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, page["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "page_permissions", "page_id", page_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = page["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
if page["collection_id"]:
|
||||
coll_role = self._collection_role(conn, page["collection_id"])
|
||||
if coll_role:
|
||||
return coll_role
|
||||
return self.role_in_workspace(page["workspace_id"])
|
||||
return self._cached(f"page:{page_id}", 60, _resolve)
|
||||
|
||||
def can_view_page(self, page_id: int) -> bool:
|
||||
return self.get_page_permission(page_id) is not None
|
||||
|
||||
def can_edit_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_comment_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["commenter"])
|
||||
|
||||
def can_manage_page_permissions(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Collection-level ──
|
||||
|
||||
def _collection_role(self, conn, collection_id: int) -> str | None:
|
||||
coll = conn.execute(
|
||||
"SELECT permission_type, workspace_id FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, coll["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "collection_permissions", "collection_id", collection_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = coll["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
return self.role_in_workspace(coll["workspace_id"])
|
||||
|
||||
def get_collection_permission(self, collection_id: int) -> str | None:
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
return self._collection_role(conn, collection_id)
|
||||
return self._cached(f"collection:{collection_id}", 60, _resolve)
|
||||
|
||||
def can_view_collection(self, collection_id: int) -> bool:
|
||||
return self.get_collection_permission(collection_id) is not None
|
||||
|
||||
def can_edit_collection(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_manage_collection_permissions(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Property-level ──
|
||||
|
||||
def _property_grants_exist(self, conn, property_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM property_permissions WHERE property_id=? LIMIT 1",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _has_property_grant(self, conn, property_id: int, min_rank: int) -> bool:
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM property_permissions WHERE property_id=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(property_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT role FROM property_permissions WHERE property_id=? AND user_id=?",
|
||||
(property_id, self.user_id),
|
||||
).fetchall()
|
||||
return any(_PROPERTY_RANK.get(r["role"], -1) >= min_rank for r in rows)
|
||||
|
||||
def can_view_property(self, collection_id: int, property_id: int) -> bool:
|
||||
"""A property is visible unless it carries explicit grants excluding
|
||||
the user; without any grant it inherits from the collection. Collection
|
||||
owners/admins always see every property."""
|
||||
if not self.can_view_collection(collection_id):
|
||||
return False
|
||||
return self._cached(
|
||||
f"prop:{property_id}", 60, lambda: self._property_visible(collection_id, property_id)
|
||||
)
|
||||
|
||||
def _collection_workspace_id(self, conn, collection_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["workspace_id"] if row else None
|
||||
|
||||
def _property_visible(self, collection_id: int, property_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["viewer"])
|
||||
|
||||
def can_edit_property(self, collection_id: int, property_id: int) -> bool:
|
||||
if not self.can_edit_collection(collection_id):
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["editor"])
|
||||
|
||||
def get_visible_properties(self, collection_id: int) -> list[int]:
|
||||
def _resolve() -> list[int]:
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [p["id"] for p in props if self.can_view_property(collection_id, p["id"])]
|
||||
return self._cached(f"visible_props:{collection_id}", 60, _resolve)
|
||||
|
||||
# ── Groups ──
|
||||
|
||||
def is_workspace_admin(self, workspace_id: int | None) -> bool:
|
||||
with get_conn() as conn:
|
||||
return self._is_admin(conn) or self._owns_workspace(conn, workspace_id)
|
||||
|
||||
def create_group(self, workspace_id: int | None, name: str,
|
||||
description: str = "", created_by: int | None = None) -> int:
|
||||
if not self.is_workspace_admin(workspace_id):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can create groups")
|
||||
if not name.strip():
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
dupe = conn.execute(
|
||||
"SELECT id FROM user_groups WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, name.strip()),
|
||||
).fetchone()
|
||||
if dupe:
|
||||
raise HTTPException(400, "A group with this name already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(workspace_id, name.strip(), description or "", created_by),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def add_user_to_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
group = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not group:
|
||||
raise HTTPException(404, "Group not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_user_from_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM group_members WHERE group_id=? AND user_id=?",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_group(self, group_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_groups WHERE id=?", (group_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_groups_for_workspace(self, workspace_id: int | None) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.id, g.name, g.description, g.created_by, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.group_id=g.id) AS member_count
|
||||
FROM user_groups g WHERE g.workspace_id IS ? ORDER BY g.name""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_group_members(self, group_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.email, m.joined_at
|
||||
FROM group_members m JOIN users u ON u.id=m.user_id
|
||||
WHERE m.group_id=? ORDER BY u.login""",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Audit log ──
|
||||
|
||||
def log_permission_change(self, resource_type: str, resource_id: int, action: str,
|
||||
target_user_id: int | None = None,
|
||||
target_group_id: int | None = None,
|
||||
old_role: str | None = None,
|
||||
new_role: str | None = None,
|
||||
ip_address: str = "") -> None:
|
||||
"""Write one immutable audit row for a permission change."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO permission_audit_log
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, performed_by, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, self.user_id, ip_address),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # audit must never break the caller
|
||||
logger.warning("permission audit log failed: %s", exc)
|
||||
@@ -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, ""
|
||||
@@ -136,6 +136,9 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list]
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "person":
|
||||
if not isinstance(value, list):
|
||||
return False, "Person must be a list of workspace members"
|
||||
elif prop_type == "url":
|
||||
if not isinstance(value, str):
|
||||
return False, "URL must be a string"
|
||||
@@ -149,17 +152,141 @@ 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 user_ref(user: dict | None) -> dict | None:
|
||||
"""Normalize a session user dict into the stored ``person`` value shape."""
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"login": user.get("login") or user.get("full_name") or "",
|
||||
"full_name": user.get("full_name") or "",
|
||||
"avatar_url": user.get("avatar_url") or "",
|
||||
"avatar_color": user.get("avatar_color") or "#3A3A3A",
|
||||
}
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
||||
"""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")}
|
||||
return None
|
||||
return user_ref(user)
|
||||
return None
|
||||
|
||||
|
||||
def apply_auto_properties(
|
||||
properties: list[dict],
|
||||
values: dict,
|
||||
user: dict | None = None,
|
||||
*,
|
||||
is_create: bool = False,
|
||||
now: str | None = None,
|
||||
) -> dict:
|
||||
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
|
||||
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
|
||||
|
||||
``created_*`` are only written on creation (or when missing); ``last_edited_*``
|
||||
are refreshed on every call. Returns the mutated dict.
|
||||
"""
|
||||
if values is None:
|
||||
values = {}
|
||||
stamp = now or datetime.now(UTC).isoformat()
|
||||
for prop in properties or []:
|
||||
ptype = prop.get("prop_type")
|
||||
if ptype not in AUTO_TYPES:
|
||||
continue
|
||||
pid = str(prop.get("id"))
|
||||
if ptype == "created_time":
|
||||
if is_create or pid not in values or values.get(pid) in (None, ""):
|
||||
values[pid] = stamp
|
||||
elif ptype == "last_edited_time":
|
||||
values[pid] = stamp
|
||||
elif ptype == "created_by":
|
||||
if is_create or pid not in values or values.get(pid) in (None, ""):
|
||||
values[pid] = user_ref(user)
|
||||
elif ptype == "last_edited_by":
|
||||
values[pid] = user_ref(user)
|
||||
return values
|
||||
|
||||
|
||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
||||
"""Get the next unique_id for a collection (max + 1)."""
|
||||
row = conn.execute(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — v6.4.0 Realtime: résolution de conflits au-delà du last-write-wins.
|
||||
|
||||
Le LWW par bloc (v5.13.0) écrase intégralement le bloc du dernier arrivé : si deux
|
||||
utilisateurs tapent dans le *même* bloc, la saisie du premier est perdue. Ce module
|
||||
implémente un vrai *merge à trois versions* (diff3-lite) :
|
||||
|
||||
base = l'état du bloc dont le client dérive sa saisie (envoyé avec l'op)
|
||||
current = l'état actuel du bloc côté serveur (déjà mis à jour par d'autres)
|
||||
incoming = la nouvelle proposition du client
|
||||
|
||||
Règle champ-par-champ :
|
||||
* incoming == base → le client n'a pas touché ce champ → on garde current
|
||||
* current == base → le serveur n'a pas touché ce champ → on garde incoming
|
||||
* current == incoming → les deux ont fait la même chose → sans conflit
|
||||
* sinon (conflit)
|
||||
- champ texte (str) : merge de caractères. Les régions modifiées qui ne se
|
||||
chevauchent pas sont *toutes conservées* (les deux saisies survivent) ;
|
||||
chevauchement réel → LWW sur ce champ + drapeau de conflit.
|
||||
- autre type (bool, nombre…) : LWW sur ce champ + drapeau de conflit.
|
||||
|
||||
Toutes les fonctions sont pures et testables sans WebSocket ni base de données.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["merge_text_3way", "merge_block_3way", "changed_region"]
|
||||
|
||||
|
||||
def changed_region(base: str, other: str) -> tuple[int, int, str] | None:
|
||||
"""Région de `base` remplacée par `other` (trim préfixe/suffixe commun).
|
||||
|
||||
Retourne ``(start, end, replacement)`` tel que ``base[:start] + replacement +
|
||||
base[end:] == other``, ou ``None`` si ``other == base`` (aucun changement).
|
||||
"""
|
||||
if base == other:
|
||||
return None
|
||||
minlen = min(len(base), len(other))
|
||||
prefix = 0
|
||||
while prefix < minlen and base[prefix] == other[prefix]:
|
||||
prefix += 1
|
||||
suffix = 0
|
||||
# ne jamais chevaucher le préfixe déjà consommé
|
||||
while (suffix < len(base) - prefix and suffix < len(other) - prefix
|
||||
and base[len(base) - 1 - suffix] == other[len(other) - 1 - suffix]):
|
||||
suffix += 1
|
||||
return prefix, len(base) - suffix, other[prefix:len(other) - suffix]
|
||||
|
||||
|
||||
def merge_text_3way(base: str, current: str, incoming: str) -> tuple[str, bool]:
|
||||
"""Merge à trois versions d'une chaîne. Retourne ``(texte, conflit)``.
|
||||
|
||||
Les éditions qui ne se chevauchent pas sont toutes les deux conservées ;
|
||||
un chevauchement réel retombe en LWW (``incoming`` gagne) et signale le conflit.
|
||||
"""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
|
||||
rc = changed_region(base, current)
|
||||
ri = changed_region(base, incoming)
|
||||
if rc is None:
|
||||
return incoming, False
|
||||
if ri is None:
|
||||
return current, False
|
||||
|
||||
c_start, c_end, c_text = rc
|
||||
i_start, i_end, i_text = ri
|
||||
|
||||
# Régions disjointes (ou juste adjacentes) → appliquer les deux sur base.
|
||||
if c_end <= i_start or i_end <= c_start:
|
||||
edits = sorted([(c_start, c_end, c_text), (i_start, i_end, i_text)],
|
||||
key=lambda e: e[0])
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for start, end, text in edits:
|
||||
if start < pos:
|
||||
continue # sécurité: ne jamais réappliquer par-dessus
|
||||
out.append(base[pos:start])
|
||||
out.append(text)
|
||||
pos = end
|
||||
out.append(base[pos:])
|
||||
return "".join(out), False
|
||||
|
||||
# Chevauchement réel → LWW sur ce champ, conflit signalé.
|
||||
return incoming, True
|
||||
|
||||
|
||||
def _scalar_conflict(base: Any, current: Any, incoming: Any) -> tuple[Any, bool]:
|
||||
"""Conflit sur un champ non-texte : LWW (incoming gagne)."""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
return incoming, True
|
||||
|
||||
|
||||
def merge_block_3way(base_blk: Any, current_blk: Any,
|
||||
incoming_blk: Any) -> tuple[dict, list[str]]:
|
||||
"""Merge à trois versions d'un bloc entier.
|
||||
|
||||
Retourne ``(bloc fusionné, champs en conflit)``. Ne lève jamais d'exception :
|
||||
une entrée non-dict retombe en LWW (``incoming``) avec conflit signalé sur
|
||||
``__block__`` pour que l'appelant puisse journaliser.
|
||||
"""
|
||||
if not isinstance(base_blk, dict):
|
||||
base_blk = {}
|
||||
if not isinstance(current_blk, dict):
|
||||
current_blk = {}
|
||||
if not isinstance(incoming_blk, dict):
|
||||
# proposition invalide → on garde l'état serveur
|
||||
return dict(current_blk), ["__block__"]
|
||||
|
||||
keys = set(base_blk) | set(current_blk) | set(incoming_blk)
|
||||
merged: dict[str, Any] = {}
|
||||
conflicts: list[str] = []
|
||||
|
||||
for key in keys:
|
||||
b = base_blk.get(key)
|
||||
c = current_blk.get(key)
|
||||
i = incoming_blk.get(key)
|
||||
|
||||
if i == b:
|
||||
# le client n'a pas modifié ce champ → valeur serveur. Si le serveur
|
||||
# a *supprimé* le champ (absent de current) alors la suppression doit
|
||||
# gagner : on n'insère pas de clé fantôme value=None.
|
||||
if key not in current_blk:
|
||||
continue
|
||||
merged[key] = c
|
||||
elif c == b:
|
||||
# le serveur n'a pas modifié ce champ → valeur client
|
||||
merged[key] = i
|
||||
elif c == i:
|
||||
merged[key] = c
|
||||
else:
|
||||
# les deux ont changé, différemment
|
||||
if isinstance(b, str) and isinstance(c, str) and isinstance(i, str):
|
||||
text, conflict = merge_text_3way(b, c, i)
|
||||
merged[key] = text
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
else:
|
||||
value, conflict = _scalar_conflict(b, c, i)
|
||||
merged[key] = value
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
|
||||
# Un champ supprimé par le serveur et absent de la proposition client doit
|
||||
# rester supprimé (pas de réapparition d'une valeur None fantôme).
|
||||
merged = {k: v for k, v in merged.items()
|
||||
if not (v is None and k not in incoming_blk)}
|
||||
|
||||
if "id" not in merged:
|
||||
# garantir l'identité du bloc même si base était vide
|
||||
ident = incoming_blk.get("id") or current_blk.get("id")
|
||||
if ident:
|
||||
merged["id"] = ident
|
||||
|
||||
return merged, conflicts
|
||||
@@ -0,0 +1,530 @@
|
||||
"""FlowDeck — v6.4.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge à trois versions des opérations de blocs (au-delà du last-write-wins) +
|
||||
version de page.
|
||||
|
||||
Améliorations « production » par rapport à v5.13.0 :
|
||||
|
||||
* **Conflits** — les mises à jour de bloc embarquent la ``base`` dont dérive la
|
||||
saisie du client ; le serveur fait un merge 3-voix champ-par-champ + texte
|
||||
(voir ``realtime_merge``) au lieu d'écraser le bloc entier. Les deux saisies
|
||||
disjointes survivent, les chevauchements réels retombent en LWW *par champ*
|
||||
avec drapeau de conflit renvoyé au client.
|
||||
* **Échelle** — chaque connexion possède une file sortante + une tâche writer
|
||||
dédiée ; le broadcast devient non bloquant (un client lent ne bloque plus la
|
||||
room), les mises à jour de curseur se coalescent (une seule par flush), et un
|
||||
client trop lent (file pleine) est déconnecté proprement (4413).
|
||||
* **Anti-flood** — budget d'opérations par connexion (fenêtre glissante).
|
||||
* **Fuites corrigées** — une room 4404 n'est plus enregistrée ; ``room_state``
|
||||
ne crée plus d'objet None ; les rooms vides sont évacuées.
|
||||
* **Observabilité** — compteurs (rooms, conns, ops, merges, conflits, déconnexions
|
||||
lentes) exposés par ``stats()``.
|
||||
|
||||
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
|
||||
import time
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
|
||||
|
||||
# File sortante maximale par connexion au-delà de laquelle le client est
|
||||
# considéré comme trop lent et déconnecté (évite qu'une room entière stagne).
|
||||
MAX_OUT_QUEUE = 512
|
||||
# Nombre maximal d'opérations acceptées par connexion et par fenêtre (anti-flood).
|
||||
OP_WINDOW_SECONDS = 10.0
|
||||
OP_WINDOW_MAX = 400
|
||||
|
||||
|
||||
def color_for(uid: int) -> str:
|
||||
return COLORS[(uid or 0) % len(COLORS)]
|
||||
|
||||
|
||||
def block_id() -> str:
|
||||
import uuid
|
||||
return "b" + uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def ensure_block_ids(blocks: list[dict]) -> list[dict]:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Template-created pages may have been persisted without block ids; without
|
||||
them the room state is not addressable by ops and the editor ends up with
|
||||
``data-bid="undefined"`` blocks (duplicated / reordered lines).
|
||||
"""
|
||||
if not isinstance(blocks, list):
|
||||
return blocks
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = block_id()
|
||||
if isinstance(b.get("children"), list):
|
||||
ensure_block_ids(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc.
|
||||
|
||||
Conservé pour la compatibilité : tests et chemins sans ``base`` continuent
|
||||
de fonctionner. Le merge 3-voix vit dans ``RealtimeManager._apply``.
|
||||
"""
|
||||
t = op.get("type")
|
||||
if t == "insert":
|
||||
blk = op.get("block") or {}
|
||||
if not blk.get("id"):
|
||||
blk = dict(blk)
|
||||
blk["id"] = block_id()
|
||||
ensure_block_ids([blk])
|
||||
idx = op.get("index")
|
||||
if not isinstance(idx, int):
|
||||
idx = len(blocks)
|
||||
idx = max(0, min(idx, len(blocks)))
|
||||
return blocks[:idx] + [blk] + blocks[idx:]
|
||||
if t == "update":
|
||||
nb = op.get("block") or {}
|
||||
if not nb.get("id"):
|
||||
return blocks
|
||||
return [nb if b.get("id") == nb["id"] else b for b in blocks]
|
||||
if t == "delete":
|
||||
bid = op.get("id")
|
||||
return [b for b in blocks if b.get("id") != bid]
|
||||
if t == "move":
|
||||
bid = op.get("id")
|
||||
idx = op.get("index", 0) or 0
|
||||
out = [b for b in blocks if b.get("id") != bid]
|
||||
idx = max(0, min(idx, len(out)))
|
||||
moved = next((b for b in blocks if b.get("id") == bid), None)
|
||||
if moved is None:
|
||||
return blocks
|
||||
out.insert(idx, moved)
|
||||
return out
|
||||
return blocks
|
||||
|
||||
|
||||
def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
|
||||
"""Apply a batch of ops sequentially (arrival order)."""
|
||||
out = blocks
|
||||
for op in ops or []:
|
||||
out = apply_op(out, op)
|
||||
return out
|
||||
|
||||
|
||||
class RTConn:
|
||||
"""Une connexion WS : file sortante + tâche writer dédiée.
|
||||
|
||||
Le broadcast ne fait que ``put_nowait`` dans la file ; c'est la tâche writer
|
||||
qui consomme et écrit sur le socket. Un client lent n'empêche donc jamais
|
||||
les autres membres de la room de recevoir les messages.
|
||||
"""
|
||||
__slots__ = ("ws", "user", "page_id", "out_q", "writer", "closed",
|
||||
"_ops_count", "_ops_window_start", "created_at")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
self.out_q: asyncio.Queue = asyncio.Queue(maxsize=MAX_OUT_QUEUE)
|
||||
self.writer: asyncio.Task | None = None
|
||||
self.closed = False
|
||||
self._ops_count = 0
|
||||
self._ops_window_start = time.monotonic()
|
||||
self.created_at = time.monotonic()
|
||||
|
||||
def op_budget_ok(self) -> bool:
|
||||
"""Fenêtre glissante simple anti-flood d'opérations."""
|
||||
now = time.monotonic()
|
||||
if now - self._ops_window_start > OP_WINDOW_SECONDS:
|
||||
self._ops_window_start = now
|
||||
self._ops_count = 0
|
||||
self._ops_count += 1
|
||||
return self._ops_count <= OP_WINDOW_MAX
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ("page_id", "blocks", "title", "version", "conns",
|
||||
"persist_task", "dirty", "merge_count", "conflict_count")
|
||||
|
||||
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
|
||||
self.merge_count = 0
|
||||
self.conflict_count = 0
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self._rooms: dict[int, Room] = {}
|
||||
# compteurs globaux (observabilité)
|
||||
self.stat_ops = 0
|
||||
self.stat_merges = 0
|
||||
self.stat_conflicts = 0
|
||||
self.stat_slow_disconnects = 0
|
||||
self.stat_connections_total = 0
|
||||
|
||||
# ── rooms ────────────────────────────────────────────────────────────
|
||||
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:
|
||||
blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
# v6.5.0: rooms serve server-resolved synced blocks so a
|
||||
# sync_req never returns a stale cached copy.
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
room.blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
|
||||
# ── entrées / sorties ────────────────────────────────────────────────
|
||||
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
|
||||
# Ne JAMAIS enregistrer la room avant d'avoir validé l'existence de la
|
||||
# page : avant, un 4404 laissait une Room orpheline en mémoire pour
|
||||
# toujours (fuite).
|
||||
existing = self._rooms.get(page_id)
|
||||
room = existing if existing is not None else Room(page_id)
|
||||
if not room.conns and not await self.load_room(room):
|
||||
await ws.close(code=4404)
|
||||
return None # room non enregistrée → pas de fuite
|
||||
if existing is None:
|
||||
self._rooms[page_id] = room
|
||||
|
||||
conn = RTConn(ws, user, page_id)
|
||||
self.stat_connections_total += 1
|
||||
conn.writer = asyncio.create_task(self._writer(conn))
|
||||
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})
|
||||
await self._broadcast(room, {"t": "peer_join", "peer": me}, exclude=conn)
|
||||
return conn
|
||||
|
||||
async def disconnect(self, conn: RTConn):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if room is None:
|
||||
self._close_writer(conn)
|
||||
return
|
||||
room.conns.discard(conn)
|
||||
self._close_writer(conn)
|
||||
await self._broadcast(room, {"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
if not room.conns:
|
||||
await self.flush(room)
|
||||
self._rooms.pop(conn.page_id, None)
|
||||
|
||||
def _close_writer(self, conn: RTConn):
|
||||
conn.closed = True
|
||||
t = conn.writer
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
conn.writer = None
|
||||
|
||||
async def _writer(self, conn: RTConn):
|
||||
"""Tâche dédiée : consomme la file sortante et écrit sur le socket.
|
||||
|
||||
Sert aussi de filet de coalescence : après chaque message consommé, les
|
||||
mises à jour de curseur (``sel``) déjà empilées sont réduites à la
|
||||
dernière (les curseurs n'ont pas besoin d'être ordonnés entre eux, seule
|
||||
la position la plus récente compte).
|
||||
"""
|
||||
try:
|
||||
while not conn.closed:
|
||||
msg = await conn.out_q.get()
|
||||
if msg is None:
|
||||
return
|
||||
await conn.ws.send_json(msg)
|
||||
# coalescence des curseurs en attente
|
||||
pending_sel = []
|
||||
while not conn.out_q.empty():
|
||||
nxt = conn.out_q.get_nowait()
|
||||
if nxt is None:
|
||||
return
|
||||
if isinstance(nxt, dict) and nxt.get("t") == "sel":
|
||||
pending_sel.append(nxt)
|
||||
else:
|
||||
await conn.ws.send_json(nxt)
|
||||
if pending_sel:
|
||||
await conn.ws.send_json(pending_sel[-1])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # socket mort → on arrête proprement
|
||||
logger.debug("writer stopped: %s", e)
|
||||
conn.closed = True
|
||||
|
||||
# ── persistance ──────────────────────────────────────────────────────
|
||||
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())
|
||||
|
||||
# ── broadcast non bloquant ───────────────────────────────────────────
|
||||
def _enqueue(self, conn: RTConn, msg: dict) -> bool:
|
||||
"""Pose le message dans la file du client. Retourne False si trop lent."""
|
||||
if conn.closed:
|
||||
return False
|
||||
try:
|
||||
conn.out_q.put_nowait(msg)
|
||||
return True
|
||||
except asyncio.QueueFull:
|
||||
return False
|
||||
|
||||
async def _evict_slow(self, room: Room, conn: RTConn):
|
||||
"""Client dépassé : on le déconnecte pour ne pas figer la room."""
|
||||
self.stat_slow_disconnects += 1
|
||||
logger.info("realtime: evicting slow client (user=%s page=%s)",
|
||||
conn.user.get("login"), conn.page_id)
|
||||
room.conns.discard(conn)
|
||||
self._close_writer(conn)
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
slow: list[RTConn] = []
|
||||
for c in list(room.conns):
|
||||
if c is exclude:
|
||||
continue
|
||||
if not self._enqueue(c, msg):
|
||||
slow.append(c)
|
||||
for c in slow:
|
||||
await self._evict_slow(room, c)
|
||||
|
||||
async def _send(self, conn: RTConn, msg: dict):
|
||||
if not self._enqueue(conn, msg):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if room is not None:
|
||||
await self._evict_slow(room, conn)
|
||||
|
||||
# ── protocole ────────────────────────────────────────────────────────
|
||||
async def handle(self, conn: RTConn, msg: dict):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if room is None:
|
||||
return
|
||||
t = msg.get("t")
|
||||
me = (conn.user.get("id") or 0)
|
||||
|
||||
if t == "hello":
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "sync_req":
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "ping":
|
||||
await self._send(conn, {"t": "pong"})
|
||||
return
|
||||
|
||||
if t == "op":
|
||||
if not conn.op_budget_ok():
|
||||
# trop d'ops : on ignore silencieusement (le client resync)
|
||||
await self._send(conn, {"t": "ack", "v": room.version,
|
||||
"stale": True})
|
||||
return
|
||||
op = msg.get("op") or {}
|
||||
client_v = msg.get("v", 0)
|
||||
result = self._apply(room, op)
|
||||
room.version += 1
|
||||
self.stat_ops += 1
|
||||
self._schedule_persist(room)
|
||||
stale = client_v < room.version - 1
|
||||
|
||||
# broadcast : on diffuse TOUJOURS le bloc final fusionné (pas la
|
||||
# proposition brute) pour que tous les clients convergent.
|
||||
out_op = dict(op)
|
||||
if result.get("merged") is not None:
|
||||
out_op["block"] = result["merged"]
|
||||
out_op["merged"] = True
|
||||
await self._broadcast(room, {"t": "op", "op": out_op, "from": me,
|
||||
"v": room.version,
|
||||
"conflict": result.get("conflict", False)},
|
||||
exclude=conn)
|
||||
|
||||
ack = {"t": "ack", "v": room.version, "stale": stale}
|
||||
if result.get("merged") is not None:
|
||||
ack["merged"] = result["merged"]
|
||||
ack["conflict"] = result.get("conflict", False)
|
||||
await self._send(conn, ack)
|
||||
|
||||
if stale:
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
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)
|
||||
await self._send(conn, {"t": "ack", "v": room.version, "stale": False})
|
||||
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
|
||||
|
||||
def _apply(self, room: Room, op: dict) -> dict:
|
||||
"""Applique une opération en mergeant à 3 voix si le client fournit
|
||||
une ``base``. Retourne ``{"merged": bloc|None, "conflict": bool}``.
|
||||
|
||||
* ``update`` avec ``base`` → merge 3-voix (au-delà du LWW).
|
||||
* le reste (insert/delete/move, ou update sans base) → LWW historique.
|
||||
"""
|
||||
if op.get("type") != "update" or "base" not in op:
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
incoming = op.get("block") or {}
|
||||
base = op.get("base")
|
||||
bid = incoming.get("id")
|
||||
if not bid:
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
current = next((b for b in room.blocks if b.get("id") == bid), None)
|
||||
if current is None:
|
||||
# bloc introuvable : LWW historique = pas d'update possible
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
merged, conflicts = merge_block_3way(base, current, incoming)
|
||||
room.merge_count += 1
|
||||
conflict = bool(conflicts)
|
||||
if conflict:
|
||||
room.conflict_count += 1
|
||||
self.stat_conflicts += 1
|
||||
logger.debug("realtime conflict page=%s block=%s fields=%s",
|
||||
room.page_id, bid, conflicts)
|
||||
self.stat_merges += 1
|
||||
room.blocks = [merged if b.get("id") == bid else b for b in room.blocks]
|
||||
return {"merged": merged, "conflict": conflict}
|
||||
|
||||
# ── observabilité ────────────────────────────────────────────────────
|
||||
async def room_state(self, page_id: int) -> dict:
|
||||
room = self._rooms.get(page_id)
|
||||
if room is None:
|
||||
# ne pas créer d'objet None : on charge dans une room jetable
|
||||
room = Room(page_id)
|
||||
if not await self.load_room(room):
|
||||
return {"blocks": [], "title": "", "version": 0}
|
||||
elif not room.conns and not room.blocks:
|
||||
await self.load_room(room)
|
||||
return {"blocks": room.blocks, "title": room.title, "version": room.version}
|
||||
|
||||
def stats(self) -> dict:
|
||||
rooms = len(self._rooms)
|
||||
conns = sum(len(r.conns) for r in self._rooms.values())
|
||||
return {
|
||||
"rooms": rooms,
|
||||
"connections": conns,
|
||||
"connections_total": self.stat_connections_total,
|
||||
"ops": self.stat_ops,
|
||||
"merges": self.stat_merges,
|
||||
"conflicts": self.stat_conflicts,
|
||||
"slow_disconnects": self.stat_slow_disconnects,
|
||||
"pages": [{"page_id": r.page_id, "conns": len(r.conns),
|
||||
"version": r.version, "merges": r.merge_count,
|
||||
"conflicts": r.conflict_count}
|
||||
for r in self._rooms.values()],
|
||||
}
|
||||
|
||||
async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None:
|
||||
"""Broadcast a synced-block event to the given pages' open rooms."""
|
||||
for pid in pages:
|
||||
room = self._rooms.get(pid)
|
||||
if room and room.conns:
|
||||
await self.load_room(room)
|
||||
await self._broadcast(room, {"t": "synced_update",
|
||||
"synced_id": synced_id,
|
||||
"version": room.version})
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.services.synced_blocks import page_ids_for_synced
|
||||
try:
|
||||
pages = page_ids_for_synced(synced_id)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast_synced_to(pages, synced_id)
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
@@ -0,0 +1,273 @@
|
||||
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
|
||||
|
||||
A recurring row stores its rule in ``property_values_json`` under the special
|
||||
key ``__recurrence__``::
|
||||
|
||||
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
|
||||
"count": null, "until": null, "byweekday": [0,2,4],
|
||||
"timezone": "Europe/Paris"} }
|
||||
|
||||
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
|
||||
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
|
||||
on the fly for a visible window — occurrences are virtual, never persisted.
|
||||
|
||||
All pure-Python (stdlib only) so it is trivially testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _cal
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FREQS = ("daily", "weekly", "monthly")
|
||||
# Monday-first weekday numbering (matches the UI calendar grid).
|
||||
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
|
||||
|
||||
RECURRENCE_KEY = "__recurrence__"
|
||||
TIMEZONE_KEY = "__timezone__"
|
||||
|
||||
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
def _zone(tz_name: str):
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_valid_timezone(tz_name: str) -> bool:
|
||||
if not tz_name:
|
||||
return True
|
||||
try:
|
||||
return tz_name in available_timezones() or tz_name == "UTC"
|
||||
except Exception:
|
||||
return False
|
||||
except Exception: # pragma: no cover - environment without tz database
|
||||
def _zone(tz_name: str):
|
||||
return None
|
||||
|
||||
def is_valid_timezone(tz_name: str) -> bool:
|
||||
return True # no tz database → accept anything (zoneinfo absent)
|
||||
|
||||
|
||||
def parse_date(value) -> dt.date | None:
|
||||
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
|
||||
s = str(value or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace(" ", "T")
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
return dt.datetime.strptime(s[:19], fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return dt.date.fromisoformat(s[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_rule(rule) -> tuple[bool, str]:
|
||||
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
|
||||
if rule is None:
|
||||
return True, ""
|
||||
if not isinstance(rule, dict):
|
||||
return False, "Recurrence must be an object"
|
||||
freq = rule.get("freq")
|
||||
if freq not in FREQS:
|
||||
return False, f"freq must be one of {FREQS}"
|
||||
interval = rule.get("interval", 1)
|
||||
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
|
||||
return False, "interval must be an integer between 1 and 365"
|
||||
count = rule.get("count")
|
||||
if count is not None:
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
|
||||
return False, "count must be an integer between 1 and 1000"
|
||||
until = rule.get("until")
|
||||
if until not in (None, "") and parse_date(until) is None:
|
||||
return False, "until must be a YYYY-MM-DD date"
|
||||
if count is not None and until not in (None, ""):
|
||||
return False, "count and until are mutually exclusive"
|
||||
bywd = rule.get("byweekday")
|
||||
if bywd not in (None, []):
|
||||
if not isinstance(bywd, list) or any(
|
||||
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
|
||||
):
|
||||
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
|
||||
if freq != "weekly":
|
||||
return False, "byweekday is only valid for weekly recurrence"
|
||||
tz = rule.get("timezone")
|
||||
if tz not in (None, "") and not is_valid_timezone(tz):
|
||||
return False, f"unknown timezone '{tz}'"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _generate_occurrences(base: dt.date, rule: dict):
|
||||
"""Yield every occurrence date in chronological order, honouring
|
||||
freq/interval/count/until/byweekday. Infinite rules yield forever —
|
||||
the caller must bound the consumption (see expand_rule)."""
|
||||
freq = rule.get("freq", "daily")
|
||||
interval = max(1, int(rule.get("interval") or 1))
|
||||
count = rule.get("count")
|
||||
until = parse_date(rule.get("until")) if rule.get("until") else None
|
||||
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
|
||||
|
||||
produced = 0
|
||||
|
||||
def limit_hit(d: dt.date) -> bool:
|
||||
return (count is not None and produced >= count) or bool(until and d > until)
|
||||
|
||||
if freq == "daily":
|
||||
d = base
|
||||
while not limit_hit(d):
|
||||
yield d
|
||||
produced += 1
|
||||
d += dt.timedelta(days=interval)
|
||||
elif freq == "weekly":
|
||||
# Monday=0 weekday numbering (matches the UI calendar grid).
|
||||
days = weekdays or [(base.isoweekday() - 1) % 7]
|
||||
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
|
||||
w = week0
|
||||
while True:
|
||||
for off in days:
|
||||
d = w + dt.timedelta(days=off)
|
||||
if d < base or limit_hit(d):
|
||||
continue
|
||||
yield d
|
||||
produced += 1
|
||||
w += dt.timedelta(weeks=interval)
|
||||
if limit_hit(w):
|
||||
return
|
||||
else: # monthly
|
||||
i = 0
|
||||
while True:
|
||||
d = _nth_month_occurrence(base, i * interval)
|
||||
if limit_hit(d):
|
||||
return
|
||||
yield d
|
||||
produced += 1
|
||||
i += 1
|
||||
|
||||
|
||||
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
|
||||
max_occurrences: int = 500) -> list[str]:
|
||||
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
|
||||
window [window_start, window_end] for a rule anchored at ``base_value``.
|
||||
|
||||
Occurrences before the window are skipped (but still count against
|
||||
``count``); the expansion is hard-capped so pathological rules can never
|
||||
blow up a request.
|
||||
"""
|
||||
base = parse_date(base_value)
|
||||
ok, _err = validate_rule(rule)
|
||||
if base is None or rule is None or not ok:
|
||||
return []
|
||||
|
||||
out: list[str] = []
|
||||
scanned = 0
|
||||
for d in _generate_occurrences(base, rule or {}):
|
||||
if d > window_end:
|
||||
break
|
||||
scanned += 1
|
||||
if scanned > 200000: # safety valve for old, very long-running rules
|
||||
break
|
||||
if d >= window_start:
|
||||
out.append(d.isoformat())
|
||||
if len(out) >= max_occurrences:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
|
||||
month_index = (base.year * 12 + (base.month - 1)) + months_offset
|
||||
year, month0 = divmod(month_index, 12)
|
||||
month = month0 + 1
|
||||
day = min(base.day, _cal.monthrange(year, month)[1])
|
||||
return dt.date(year, month, day)
|
||||
|
||||
|
||||
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
|
||||
"""Current time in the given IANA timezone (fallback: UTC)."""
|
||||
zone = _zone(tz_name) if tz_name else None
|
||||
if zone is not None:
|
||||
return dt.datetime.now(zone)
|
||||
return dt.datetime.now(dt.UTC)
|
||||
|
||||
|
||||
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
|
||||
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
|
||||
return now_in_tz(tz_name).date()
|
||||
|
||||
|
||||
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
|
||||
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
|
||||
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
|
||||
hh, mm = 9, 0
|
||||
if time_str and len(time_str) >= 16:
|
||||
try:
|
||||
hh, mm = int(time_str[11:13]), int(time_str[14:16])
|
||||
except ValueError:
|
||||
pass
|
||||
zone = _zone(tz_name) if tz_name else None
|
||||
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
|
||||
if zone is not None:
|
||||
return naive.replace(tzinfo=zone)
|
||||
return naive.replace(tzinfo=dt.UTC)
|
||||
|
||||
|
||||
def next_occurrences(base_value, rule: dict, tz_name: str | None,
|
||||
after: dt.datetime, horizon_days: int = 365,
|
||||
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
|
||||
"""Upcoming occurrences (date, aware event datetime) strictly on/after
|
||||
``after`` for a row anchored at ``base_value``. Non-recurring rules
|
||||
(rule falsy) yield the single base occurrence. Bounded by horizon."""
|
||||
base = parse_date(base_value)
|
||||
if base is None:
|
||||
return []
|
||||
time_str = str(base_value or "")
|
||||
start = after.date()
|
||||
if not rule:
|
||||
event = _zone_dt(base, time_str, tz_name)
|
||||
return [(base, event)] if event >= after else []
|
||||
ok, _ = validate_rule(rule)
|
||||
if not ok:
|
||||
return []
|
||||
out: list[tuple[dt.date, dt.datetime]] = []
|
||||
window_end = start + dt.timedelta(days=horizon_days)
|
||||
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
|
||||
d = dt.date.fromisoformat(iso)
|
||||
event = _zone_dt(d, time_str, tz_name)
|
||||
if event >= after:
|
||||
out.append((d, event))
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
|
||||
COMMON_TIMEZONES = [
|
||||
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
|
||||
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
|
||||
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
|
||||
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
|
||||
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
|
||||
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
|
||||
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
|
||||
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
|
||||
]
|
||||
|
||||
|
||||
def common_timezones() -> list[str]:
|
||||
"""Timezone list for pickers: curated common zones + all installed ones
|
||||
whose region matches, deduplicated and sorted."""
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
all_zones = sorted(available_timezones())
|
||||
# Merge: keep curated first, then everything else available.
|
||||
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
|
||||
return COMMON_TIMEZONES + extra
|
||||
except Exception:
|
||||
return list(COMMON_TIMEZONES)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
|
||||
|
||||
Reminders live on database rows, next to the recurrence rule, inside
|
||||
``property_values_json`` under the special key ``__reminder__``::
|
||||
|
||||
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
|
||||
|
||||
A background loop scans rows with reminders, computes the next occurrence
|
||||
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
|
||||
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
|
||||
through the ``reminder_log`` table keyed by (page_id, occurrence date).
|
||||
|
||||
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
|
||||
drive it deterministically with an injected ``now``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
TIMEZONE_KEY,
|
||||
next_occurrences,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REMINDER_KEY = "__reminder__"
|
||||
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
|
||||
# Fire late is still useful; ignore events that happened long ago.
|
||||
FIRE_GRACE_SECONDS = 3600
|
||||
|
||||
|
||||
def parse_lead(reminder) -> int | None:
|
||||
"""Return the lead in seconds for a reminder dict, or None if disabled."""
|
||||
if not isinstance(reminder, dict):
|
||||
return None
|
||||
unit = reminder.get("unit", "none")
|
||||
if unit not in _UNITS:
|
||||
return None
|
||||
try:
|
||||
value = int(reminder.get("value", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value * _UNITS[unit]
|
||||
|
||||
|
||||
def lead_human(reminder: dict) -> str:
|
||||
try:
|
||||
value = int(reminder.get("value", 0))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
unit = reminder.get("unit", "")
|
||||
if value == 1 and unit.endswith("s"):
|
||||
unit = unit[:-1]
|
||||
return f"{value} {unit}"
|
||||
|
||||
|
||||
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
|
||||
"""User ids assigned via any ``person`` property of the row."""
|
||||
targets: list[int] = []
|
||||
person_props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for pp in person_props:
|
||||
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
|
||||
if isinstance(value, list):
|
||||
for person in value:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
targets.append(int(person["id"]))
|
||||
return targets
|
||||
|
||||
|
||||
def _row_tz(conn, collection_id: int, props: dict) -> str:
|
||||
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
|
||||
recurrence rule tz, then the created_by user's personal timezone,
|
||||
then '' (UTC)."""
|
||||
tzmap = props.get(TIMEZONE_KEY)
|
||||
if isinstance(tzmap, dict):
|
||||
for value in tzmap.values():
|
||||
if value:
|
||||
return str(value)
|
||||
rec = props.get(RECURRENCE_KEY) or {}
|
||||
for rule in rec.values():
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
return rule["timezone"]
|
||||
auto_props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for ap in auto_props:
|
||||
value = props.get(str(ap["id"]))
|
||||
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
|
||||
for person in people:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
row = conn.execute(
|
||||
"SELECT timezone FROM users WHERE id=?", (person["id"],)
|
||||
).fetchone()
|
||||
if row and row["timezone"]:
|
||||
return row["timezone"]
|
||||
return ""
|
||||
|
||||
|
||||
def scan_and_fire(now: dt.datetime | None = None) -> int:
|
||||
"""Fire every reminder that is due. Returns the number of notifications
|
||||
created. Safe to call repeatedly (dedup via reminder_log)."""
|
||||
now = now or dt.datetime.now(dt.UTC)
|
||||
fired = 0
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
|
||||
).fetchall()
|
||||
for page in pages:
|
||||
try:
|
||||
props = json.loads(page["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
reminders = props.get(REMINDER_KEY)
|
||||
if not isinstance(reminders, dict):
|
||||
continue
|
||||
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
|
||||
for prop_key, reminder in reminders.items():
|
||||
lead = parse_lead(reminder)
|
||||
if lead is None:
|
||||
continue
|
||||
base_value = props.get(str(prop_key))
|
||||
if not base_value and str(prop_key).isdigit():
|
||||
# fall back to name-keyed storage
|
||||
pname = conn.execute(
|
||||
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
|
||||
).fetchone()
|
||||
if pname:
|
||||
base_value = props.get(pname["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rule = rec_all.get(str(prop_key))
|
||||
if rule is None and not str(prop_key).isdigit():
|
||||
pid = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(page["collection_id"], str(prop_key)),
|
||||
).fetchone()
|
||||
if pid:
|
||||
rule = rec_all.get(str(pid["id"]))
|
||||
tz_name = _row_tz(conn, page["collection_id"], props)
|
||||
# Look for the occurrence whose reminder moment is in
|
||||
# [now - grace, now].
|
||||
events = next_occurrences(
|
||||
base_value, rule, tz_name or None,
|
||||
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
|
||||
horizon_days=400, max_count=2000,
|
||||
)
|
||||
due = None
|
||||
for _d, event in events:
|
||||
fire_at = event - dt.timedelta(seconds=lead)
|
||||
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
|
||||
due = (event, fire_at)
|
||||
break
|
||||
if due is None:
|
||||
continue
|
||||
event, _fire_at = due
|
||||
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
|
||||
continue
|
||||
target_ids = _person_targets(conn, page["collection_id"], props)
|
||||
if not target_ids:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
target_ids = [row["id"]] if row else []
|
||||
for uid in dict.fromkeys(target_ids):
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
|
||||
(page["id"], event.date().isoformat()),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
continue
|
||||
from app.services import mailer, notifications
|
||||
lead_text = lead_human(reminder)
|
||||
notifications.create_notification(
|
||||
uid, None, "reminder",
|
||||
title=f"Reminder: {page['title'] or 'Untitled'}",
|
||||
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
|
||||
if lead else
|
||||
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
|
||||
resource_type="db_page",
|
||||
resource_id=page["id"],
|
||||
url=f"/collections/{page['collection_id']}",
|
||||
conn=conn, commit=False,
|
||||
)
|
||||
mailer.notify_user(
|
||||
uid,
|
||||
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
|
||||
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
|
||||
cta_url=f"/collections/{page['collection_id']}",
|
||||
prefs_key="reminders",
|
||||
)
|
||||
fired += 1
|
||||
conn.commit()
|
||||
return fired
|
||||
|
||||
|
||||
async def reminder_scheduler(interval_seconds: int = 60):
|
||||
"""Background loop: scan for due reminders once a minute."""
|
||||
import asyncio
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if not getattr(settings, "reminders_enabled", True):
|
||||
logger.info("Reminder scheduler disabled via settings")
|
||||
return
|
||||
await asyncio.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
n = scan_and_fire()
|
||||
if n:
|
||||
logger.info("Reminders: fired %d notification(s)", n)
|
||||
except Exception as exc: # pragma: no cover - defensive only
|
||||
logger.warning("Reminder scan failed: %s", exc)
|
||||
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user