feat(v5.2.0): Infrastructure & Polish - isolation tests, xdist, lint, CI #15

Merged
bruno merged 9 commits from feat/v5.4.0-v5.5.0 into main 2026-09-12 00:19:55 -04:00
55 changed files with 3985 additions and 559 deletions
-43
View File
@@ -1,43 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "script"
},
"globals": {
"Alpine": "readonly",
"htmx": "readonly",
"Sortable": "readonly",
"window": "readonly",
"document": "readonly",
"localStorage": "readonly",
"confirm": "readonly",
"fetch": "readonly",
"navigator": "readonly",
"setTimeout": "readonly",
"setInterval": "readonly",
"history": "readonly",
"Location": "readonly",
"URLSearchParams": "readonly",
"location": "readonly"
},
"rules": {
"no-unused-vars": ["warn", { "args": "none" }],
"no-undef": "error",
"no-extra-semi": "warn",
"no-empty": "warn"
},
"overrides": [
{
"files": ["static/js/**/*.js"],
"rules": {
"no-undef": "warn"
}
}
],
"ignorePatterns": ["static/js/*.min.js", "static/js/vendor/**"]
}
+21 -6
View File
@@ -1,12 +1,26 @@
name: FlowDeck CI name: FlowDeck CI
on: on:
# Run on every pushed branch so feature branches are validated before the PR.
push: push:
branches: [main]
pull_request: pull_request:
branches: [main] branches: [main, develop]
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
run: ruff check app tests
- name: ESLint (JavaScript)
run: npx --yes eslint static/js
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the # NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
@@ -19,7 +33,7 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install system dependencies (WeasyPrint / emoji fonts) - name: Install system dependencies (WeasyPrint / emoji fonts)
run: | run: |-
SUDO="" SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update $SUDO apt-get update
@@ -27,13 +41,14 @@ jobs:
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \ libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
- name: Install Python dependencies - name: Install Python dependencies
run: pip install -r requirements.txt pytest pytest-cov run: pip install -r requirements-dev.txt pytest-cov
- name: Run tests with coverage - name: Run tests (parallel) with coverage
env: env:
GITEA_URL: https://git.dracodev.net GITEA_URL: https://git.dracodev.net
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
APP_SECRET_KEY: ci-test-key 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 - name: Coverage summary
if: always() if: always()
run: coverage report -m || true run: coverage report -m || true
+53
View File
@@ -1,5 +1,58 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
> (design tokens/components, API tokens, sessions, onboarding, backups, projets +
> sync, GitHubAdapter, Docker multi-stage) ; ce patch sécurise l'isolation des
> tests, active les tests parallèles et rend le linting vert.
- **Tests parallèles (pytest-xdist)** — `tests/conftest.py` **mute** désormais le
singleton `app.config.settings` au lieu de le remplacer. Les modules qui
importaient `settings` au chargement (ex. `app/services/backup.py`) gardaient
sinon les valeurs par défaut, ce qui rendait les tests backup instables. Chaque
test a une base SQLite temporaire + un dossier de backup dédiés → tests
parallèles lancés explicitement (`pytest -n auto` en local et en CI). La config
de base ne force plus `-n` (évite l'échec `unrecognized arguments: -n` si
`pytest-xdist` n'est pas installé).
- **Backups réellement testés** — les 2 tests précédemment `skip` (flaky) sont
remplacés par des tests réels : snapshot daté, `prune_old_backups`, `backup_due`
et API admin `/api/settings/backups`.
- **OAuth (mock)** — tests d'intégration complets dans `tests/test_v52_infra.py` :
redirect authorize → callback (Gitea + GitHub), mode `link` sur un compte local,
rejet d'un `state` invalide, construction des URLs d'autorisation.
- **Schéma complet hors lifespan** — `init_db()` crée aussi
`webhook_subscriptions` (auparavant uniquement dans le lifespan FastAPI).
- **Linting** — `ruff check app tests` passe sans erreur (corrections E701/E702,
B904, B007, E741, F821, F841, W293, UP031, E731 + config FastAPI pour B008) ;
migration de `.eslintrc.json` vers `eslint.config.mjs` (flat config, ESLint v9+).
- **CI** — nouveau job `lint` (ruff + eslint) ; tests exécutés en parallèle avec
couverture ; déclencheurs élargis à `develop`.
- **Version** — 5.11.1. **397 tests** verts.
## v5.11.0 (2026-09-11) — FlowDeck Agent : UX chat améliorée
> Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour
> une expérience plus proche de Notion AI.
- **Auto-titre à chaque requête** — Le titre de la conversation se met à jour
après chaque échange avec l'IA (pas seulement à la création). Il reflète
le sujet courant et rend l'historique facile à parcourir.
- **Aperçu au survol / appui long** — Dans l'onglet Historique, un survol
souris (desktop) ou un appui long (mobile) ouvre un pop-up avec le premier
message utilisateur et la dernière réponse de l'agent, sans quitter la liste.
- **Normalisation des blocs `write_blocks`** — L'outil tolère désormais les
erreurs fréquentes du LLM : `text`→`content`, `heading`→`heading_1`…`heading_4`,
`bullet/list`→`bulleted_list`, génération d'ID, types inconnus→paragraphe.
Le script PowerShell (ou tout code) s'écrit correctement dans un bloc `code`.
- **Accordéon style Notion** — Le raisonnement de l'agent s'affiche dans un
composant pliable : « Pensées » (chevron, contenu complet au clic) et
« Actions » (lignes simples, passé, non-cliquables). Le tout reste visible
en permanence sous la réponse, sans encombrer l'écran.
- **Tests** — `test_write_blocks_normalizes_llm_blocks`,
`test_engine_titles_conversation_on_each_request`,
`test_suggest_title_strips_composer_prefixes`.
## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur) ## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur)
> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne > L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne
+40 -28
View File
@@ -437,40 +437,51 @@ app/
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template) - [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 - [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
### v5.2.0 — Infrastructure & Polish 🔄 (en cours) ### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` + > **Objectif** : fondations de production — design system, sécurité, infra, forge.
> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau > **COMPLETED**.
> schéma passe désormais par des étapes versionnées (baseline v1, indexes v2, FTS5 v3).
> **État actuel** : API publique + tokens **partiellement implémentés** (`routers/public_api.py`,
> test `test_public_api_token`) — à compléter dans la section Sécurité.
**Design system** **Design system**
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés) - [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts - [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
**Sécurité & Utilisateur** **Sécurité & Utilisateur**
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur *(PARTIEL : `public_api.py` + tokens existent, manque la gestion UI dans Settings)* - [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
- [ ] **Sessions actives** — voir et révoquer les sessions - [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet) - [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
**Infrastructure** **Infrastructure**
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅ - [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
- [ ] **Backup automatique** — cron daily → fichier daté - [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] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
- [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)* - [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
- [ ] **Tests parallèles** — pytest-xdist - [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
- [ ] **Build Docker multi-stage** — optimiser taille d'image - [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
**Forge integration** **Forge integration**
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language - [x] **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) - [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter - [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
**Tests (cibles)** **Tests (cibles)**
- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅ - [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
- [ ] Tests d'intégration auth (OAuth mock) - [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
- [ ] Tests des adapters forge (mock HTTP) - [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
- [ ] Tests multi-user (permissions croisées) - [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
**✅ Validation (2026-09-11)** :
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
- `pytest -q` (suite complète) → **397 passed**
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
**Complétion du 2026-09-11** :
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
### v5.3.0 — Database Avancée ✅ (2026-09-06) ### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**. > **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
@@ -616,11 +627,12 @@ app/
## 🎯 Ordre de priorité recommandé (état 2026-09) ## 🎯 Ordre de priorité recommandé (état 2026-09)
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`) 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`) 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) 3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
1. ~~**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) 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)
2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 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** — backlinks, duplicate, corbeille globale, historique de version UI, import (prochain)
--- ---
## Résumé des phases ## Résumé des phases
@@ -639,4 +651,4 @@ Quality DB views, Agent IA Palette → Realtime + S
DB avancée, redo, drag&drop, DB avancée, redo, drag&drop,
Calendrier, AI duplicate) Calendrier, AI duplicate)
*Dernière mise à jour: 2026-09-10 — v5.9.0 AI Writing Assist livré (slash `/ai`, autocomplétion `AIAC`, AI properties, service `ai_writing.py`, 2 endpoints, 29 tests) ; reste v5.4, v5.11 → v6.0* *Dernière mise à jour: 2026-09-11 — **v5.2.0 Infrastructure & Polish validé** (18/18 tests dédiés, 397 tests suite complète, ruff/eslint verts, CI Gitea success sur push + PR #15) ; reste v5.4, v5.11 → v6.0*
+1 -1
View File
@@ -1 +1 @@
5.9.1 5.11.1
+2
View File
@@ -1,3 +1,5 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies.""" """FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user from app.auth.session import SessionManager, get_current_user
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
+5
View File
@@ -817,6 +817,11 @@ def init_db():
from app.migrations import apply_migrations from app.migrations import apply_migrations
apply_migrations(conn) 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 @contextmanager
def get_conn(): def get_conn():
+8 -4
View File
@@ -81,13 +81,17 @@ async def lifespan(_app: FastAPI):
from app.services.projects import project_sync_scheduler from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler()) projects_task = asyncio.create_task(project_sync_scheduler())
logger.info("FlowDeck v5.9.1 started on port %d", settings.app_port) # ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
try: try:
yield yield
finally: finally:
for task in (scheduler_task, automation_task, backup_task, projects_task): for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
task.cancel() task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task): for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -96,7 +100,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="5.9.1", version="5.11.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
+2
View File
@@ -1,2 +1,4 @@
"""FlowDeck — Custom middleware.""" """FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware from app.middleware.csrf import CSRFMiddleware
__all__ = ["CSRFMiddleware"]
+35
View File
@@ -300,6 +300,41 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
) )
@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(4, "database templates (icon) + property validation") @register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None: def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config, """v5.3.0: database templates get an icon, properties a validation config,
+4 -4
View File
@@ -178,7 +178,7 @@ async def create_agent(request: Request):
) )
conn.commit() conn.commit()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -466,9 +466,9 @@ async def undo(request: Request, action_id: int):
try: try:
undo_action(action_id) undo_action(action_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") from exc
return {"id": action_id, "status": "reverted"} return {"id": action_id, "status": "reverted"}
@@ -501,7 +501,7 @@ async def create_skill(request: Request):
) )
conn.commit() conn.commit()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
+4 -4
View File
@@ -149,7 +149,7 @@ async def move_card(
issue = await gitea.get_issue(owner, repo, issue_id) issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])] current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id) 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"]) filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs # Resolve label names to IDs
@@ -293,7 +293,7 @@ async def create_issue(
if not _check_rate_limit(request): if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded") 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 milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue( issue = await gitea.create_issue(
@@ -345,7 +345,7 @@ async def update_issue_api(
if state: if state:
kwargs["state"] = state kwargs["state"] = state
if labels: 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(): if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone) kwargs["milestone"] = int(milestone)
if assignee: if assignee:
@@ -388,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) comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e: except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, 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 # Get checklists from local DB
with get_conn() as conn: with get_conn() as conn:
+1 -1
View File
@@ -53,7 +53,7 @@ def _validate_payload(body: dict) -> None:
else: else:
json.dumps(val) json.dumps(val)
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations") @router.get("/workspace/automations")
+440 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@@ -150,20 +151,34 @@ def _file_icon(name: str, content_format: str = "") -> str:
if content_format and content_format != 'file': if content_format and content_format != 'file':
return 'edit' return 'edit'
n = name.lower() n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image' if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
if n.endswith('.pdf'): return 'file' return 'image'
if re.search(r'\.(md|markdown)$', n): return 'edit' if n.endswith('.pdf'):
if n.endswith('.py'): return 'file' return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file' if re.search(r'\.(md|markdown)$', n):
if re.search(r'\.(html?|xml)$', n): return 'file' return 'edit'
if n.endswith('.css'): return 'file' if n.endswith('.py'):
if n.endswith('.json'): return 'file' return 'file'
if n.endswith('.sql'): return 'file' if re.search(r'\.(js|jsx|ts|tsx)$', n):
if re.search(r'\.(sh|bash|zsh)$', n): return 'file' return 'file'
if n.endswith('.ps1'): return 'file' if re.search(r'\.(html?|xml)$', n):
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file' return 'file'
if re.search(r'\.(txt|log)$', n): return 'file' if n.endswith('.css'):
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file' return 'file'
if n.endswith('.json'):
return 'file'
if n.endswith('.sql'):
return 'file'
if re.search(r'\.(sh|bash|zsh)$', n):
return 'file'
if n.endswith('.ps1'):
return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
return 'file'
if re.search(r'\.(txt|log)$', n):
return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
return 'file'
return 'file' return 'file'
@@ -677,6 +692,33 @@ async def update_share(request: Request, page_id: int):
return {"status": "ok", "share_mode": mode, "published": published} return {"status": "ok", "share_mode": mode, "published": published}
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
import secrets
slug = "p-" + secrets.token_urlsafe(8)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
(slug, page_id),
)
conn.commit()
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
return {"is_published": False}
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash") @router.get("/api/trash")
@@ -820,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
) )
conn.commit() conn.commit()
except Exception as e: except Exception as e:
raise HTTPException(409, f"Property already exists: {e}") raise HTTPException(409, f"Property already exists: {e}") from e
return {"status": "ok", "name": name, "type": prop_type} return {"status": "ok", "name": name, "type": prop_type}
@@ -874,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
if not issue.get("pull_request"): if not issue.get("pull_request"):
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e)) from e
return {"status": "ok", "issues_scanned": len(issues)} return {"status": "ok", "issues_scanned": len(issues)}
@@ -945,13 +987,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
@router.post("/api/pages/{page_id}/blocks") @router.post("/api/pages/{page_id}/blocks")
async def save_page_blocks(request: Request, page_id: int): async def save_page_blocks(request: Request, page_id: int):
"""Save blocks JSON content (Notion-style block editor).""" """Save blocks JSON content (Notion-style block editor).
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
"""
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(400, "Invalid JSON body") raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", [])) blocks_json = json.dumps(body.get("blocks", []))
title = body.get("title", "") title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
with get_conn() as conn: with get_conn() as conn:
if title: if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
@@ -959,16 +1008,386 @@ async def save_page_blocks(request: Request, page_id: int):
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(blocks_json, page_id), (blocks_json, page_id),
) )
_record_version(conn, page_id, uid, title or "", blocks_json)
conn.commit() conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "", await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"}) "content_format": "blocks"})
return {"status": "ok", "id": page_id} return {"status": "ok", "id": page_id}
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
"""Insert a version snapshot unless it is byte-identical to the latest one."""
prev = conn.execute(
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
(page_id,),
).fetchone()
if prev is not None and prev["blocks_json"] == blocks_json:
if prev["title"] != (title or ""):
conn.execute(
"UPDATE page_versions SET title=? WHERE id="
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
(title or "", page_id),
)
return
conn.execute(
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
(page_id, user_id, title or "", blocks_json),
)
def _block_texts(b: dict) -> list[str]:
"""Flatten a block (including children) into searchable text chunks."""
out = []
raw = b.get("content")
if isinstance(raw, str) and raw.strip():
out.append(raw)
for child in b.get("children") or []:
out.extend(_block_texts(child))
return out
@router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
"""
target = f"/pages/{page_id}" if page_id else None
backlinks = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, workspace, content, content_format, updated_at "
"FROM pages WHERE deleted_at IS NULL AND id != ?",
(page_id,),
).fetchall()
for r in rows:
fmt = r["content_format"]
hits = False
if fmt == "blocks" and r["content"]:
try:
blocks = json.loads(r["content"])
for b in blocks if isinstance(blocks, list) else []:
for text in _block_texts(b):
if target and f"/pages/{page_id}" in text:
hits = True
break
if hits:
break
except (json.JSONDecodeError, TypeError):
hits = target and f"/pages/{page_id}" in (r["content"] or "")
elif fmt == "markdown":
hits = target and f"/pages/{page_id}" in (r["content"] or "")
elif r["content"]:
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
if not hits and target:
hits = f"/pages/{page_id}" in (r["content"] or "")
if hits:
backlinks.append({
"id": r["id"],
"title": r["title"] or "Untitled",
"workspace": r["workspace"] or "",
"updated_at": r["updated_at"] or "",
})
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
return {"backlinks": backlinks}
@router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
"COALESCE(u.login, '') AS author "
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
(page_id,),
).fetchall()
return {"versions": [dict(r) for r in rows]}
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
async def restore_version(request: Request, page_id: int, version_id: int):
"""v5.4.0: restore a page from a version snapshot."""
with get_conn() as conn:
ver = conn.execute(
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
(version_id, page_id),
).fetchone()
if not ver:
raise HTTPException(404, "Version not found")
conn.execute(
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(ver["blocks_json"], ver["title"] or "", page_id),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
"content_format": "blocks"})
return {"status": "ok", "restored": version_id}
# ═══════════ v5.4.0: Page & collection duplication ═══════════
@router.post("/api/pages/{page_id}/duplicate")
async def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling."""
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
).fetchone()
if not row:
raise HTTPException(404, "Page not found")
page = dict(row)
def copy_tree(src_id: int, parent_id) -> int:
with get_conn() as conn:
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
(parent_id, src_id),
)
new_id = cur.lastrowid
conn.commit()
for child in conn.execute(
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
).fetchall():
copy_tree(child["id"], new_id)
return new_id
new_id = copy_tree(page_id, page.get("parent_id"))
title = (page.get("title") or "Untitled") + " copy"
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
conn.commit()
await fire_event("page.created", {"page_id": new_id, "title": title,
"workspace": page.get("workspace")})
return {"status": "ok", "id": new_id, "title": title}
# ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _ws_id_for(request: Request, page_id: int) -> int:
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
cookie = request.cookies.get("flowdeck_workspace", "")
try:
ws_id = int(cookie)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
if row and row["workspace_id"]:
return int(row["workspace_id"])
return 1
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
{file_url, file_path, mime_type, size, file_name}."""
import datetime
import re as _re
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
(folder / final).write_bytes(await upload.read())
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
return {
"file_url": f"/api/files/{ws_id}/{final}",
"file_path": f"uploads/workspace_{ws_id}/{final}",
"mime_type": mime,
"size": (folder / final).stat().st_size,
"file_name": name,
}
@router.post("/api/pages/{page_id}/cover")
async def set_page_cover(request: Request, page_id: int):
"""v5.5.0: upload an image cover for a page.
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
an image stored in the workspace's uploads directory.
"""
ctype = (request.headers.get("content-type") or "").lower()
if ctype.startswith("application/json"):
body = await request.json()
cover_url = (body.get("cover_url") or "").strip()
if not cover_url:
raise HTTPException(400, "cover_url required")
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
ws_id = _ws_id_for(request, page_id)
meta = await _store_uploaded_file(request, ws_id)
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
@router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit()
return {"status": "ok", "page_id": page_id}
@router.post("/api/pages/{page_id}/icon")
async def set_page_icon(request: Request, page_id: int):
"""v5.5.0: set a page emoji/icon label."""
body = await request.json()
icon = (body.get("icon") or "").strip()
if len(icon) > 16:
raise HTTPException(400, "icon too long")
with get_conn() as conn:
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "icon": icon}
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
"""Convert markdown → blocks (server-side, same mapping as the editor) and
create a page in the caller's workspace."""
from app.services.export import _md_to_blocks
blocks = _md_to_blocks(markdown or "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = user.get("login", "Bruno") if user else "Bruno"
file_title = title.strip() or "Import"
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
if not blocks:
blocks = [{"type": "paragraph", "content": markdown or ""}]
with get_conn() as conn:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
)
conn.commit()
return cur.lastrowid
@router.post("/api/pages/import")
async def import_page(request: Request):
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
markdown = body.get("markdown", "")
title = body.get("title", "")
if not markdown and not body.get("csv"):
raise HTTPException(400, "markdown field required")
if not markdown.strip():
raise HTTPException(400, "markdown is empty")
page_id = await _create_page_from_markdown(request, markdown, title)
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
"workspace": ""})
return {"status": "ok", "id": page_id}
@router.post("/api/pages/import/file")
async def import_file(request: Request):
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
markdown pages) into the workspace. Returns the created page ids."""
import io as _io
import zipfile
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
created_ids = []
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(_io.BytesIO(data))
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid zip archive") from None
md_entries = sorted(
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
key=lambda n: (n.count("/"), n.lower()),
)
if not md_entries:
raise HTTPException(400, "No .md files found in archive")
for name in md_entries:
raw = zf.read(name).decode("utf-8", errors="replace")
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
try:
created_ids.append(await _create_page_from_markdown(request, raw, title))
except Exception as exc: # noqa: BLE001 - keep importing the rest
logger.warning("import failed for %s: %s", name, exc)
else:
try:
raw = data.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(400, "Only text/markdown files are supported") from None
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
created_ids.append(await _create_page_from_markdown(request, raw, title))
if not created_ids:
raise HTTPException(422, "No pages could be imported")
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
@router.post("/api/og/metadata")
async def og_metadata(request: Request):
"""v5.5.0: Open Graph metadata for a bookmark card."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
from app.services.og_fetcher import fetch_og_metadata
data = await fetch_og_metadata(url)
return {"ok": True, **data}
@router.put("/api/pages/{page_id}/move") @router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int): async def move_page(request: Request, page_id: int):
"""Move a page to another workspace or reorder within tree. """Move a page to another workspace or reorder within tree.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
- workspace_id: move page to a different workspace - workspace_id: move page to a different workspace
- parent_id: change parent (0 = root level) - parent_id: change parent (0 = root level)
@@ -1053,7 +1472,7 @@ async def view_page(request: Request, page_id: int):
).fetchone() ).fetchone()
# Build page_data, including file metadata for uploaded files # Build page_data, including file metadata for uploaded files
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))} page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
# For file pages, extract file metadata and add to page_data # For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file": if page.get("content_format") == "file":
@@ -1121,4 +1540,4 @@ async def sync_project(owner: str, repo: str):
conn.commit() conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)} return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e)) from e
+1 -1
View File
@@ -50,7 +50,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): async def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page.""" """List page-level and inline comments for a FlowDeck page."""
user = _current_user(request) _current_user(request)
with get_conn() as conn: with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page: if not page:
+147 -9
View File
@@ -223,6 +223,144 @@ async def delete_collection_api(request: Request, collection_id: int):
return {"id": collection_id, "status": "deleted"} return {"id": collection_id, "status": "deleted"}
@router.post("/{collection_id}/duplicate")
async def duplicate_collection_api(request: Request, collection_id: int):
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
sources) into a new collection named '<original> (copy)'."""
with get_conn() as conn:
src = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not src:
raise HTTPException(status_code=404, detail="Collection not found")
new_name = (src["name"] or "Database") + " copy"
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
FROM collections WHERE id=?""",
(new_name, collection_id),
)
new_id = cur.lastrowid
# ── Properties (remap ids so relation/rollup refs stay valid) ──
prop_map: dict[int, int] = {}
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for p in rows:
ncur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
None, p["reverse_name"], None, None, p["rollup_function"],
p["formula_expression"], p["position"], p["required"],
p["visible_in_views"]),
)
prop_map[p["id"]] = ncur.lastrowid
# Fix cross-property references after all rows exist (creates may target
# columns not inserted yet). Related collection remapped to the copy.
for p in rows:
p = dict(p) # convert sqlite3.Row to dict
new_pid = prop_map[p["id"]]
related = p["related_collection_id"]
related_new = new_id if related == collection_id else related
if p["prop_type"] == "relation":
conn.execute(
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
(related_new, new_pid),
)
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
(prop_map[p["relation_property_id"]], new_pid),
)
if p.get("target_property_id") and p["target_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
(prop_map[p["target_property_id"]], new_pid),
)
# ── Views ──
vrows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for v in vrows:
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?,?,?,?,?)""",
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
)
# ── Pages (rows) with property ids remapped to the copy's properties ──
prows = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
page_map: dict[int, int] = {}
for p in prows:
try:
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
except (json.JSONDecodeError, TypeError):
pv = {}
pv_new = {}
for k, val in pv.items():
try:
prop_id = int(k)
except (ValueError, TypeError):
prop_id = None
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
pv_new[new_key] = val
ncur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, parent_id, gitea_issue_id,
gitea_issue_number, property_values_json, created_at, updated_at)
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
FROM collection_pages WHERE id=?""",
(new_id, json.dumps(pv_new), p["id"]),
)
page_map[p["id"]] = ncur.lastrowid
# Re-parent sub-items to the copied rows.
for p in prows:
if p["parent_id"] and p["parent_id"] in page_map:
conn.execute(
"UPDATE collection_pages SET parent_id=? WHERE id=?",
(page_map[p["parent_id"]], page_map[p["id"]]),
)
# ── Data sources (linked DBs) ──
drows = conn.execute(
"SELECT * FROM collection_data_sources WHERE collection_id=?",
(collection_id,),
).fetchall()
for d in drows:
src_coll = d["source_collection_id"]
src_now = new_id if src_coll == collection_id else src_coll
conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?,?,?,?,?)""",
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
)
conn.commit()
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
return {"id": new_id, "name": new_name, "status": "duplicated"}
# ── Page CRUD (standalone, BEFORE collection wildcards) ── # ── Page CRUD (standalone, BEFORE collection wildcards) ──
@@ -415,7 +553,7 @@ async def create_property_api(request: Request, collection_id: int):
) )
conn.commit() conn.commit()
except Exception: except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"} return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
@@ -841,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
except Exception: except Exception:
body = {} body = {}
new_status = body.get("new_status", "Done") body.get("new_status", "Done")
with get_conn() as conn: with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
@@ -931,7 +1069,7 @@ async def add_data_source(request: Request, collection_id: int):
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection") raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
return { return {
"id": cur.lastrowid, "id": cur.lastrowid,
@@ -970,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int):
body = {} body = {}
name = body.get("name", "").strip() name = body.get("name", "").strip()
new_workspace_id = body.get("workspace_id") body.get("workspace_id")
with get_conn() as conn: with get_conn() as conn:
source = conn.execute( source = conn.execute(
@@ -1202,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists") raise HTTPException(status_code=409, detail="This dependency already exists") from None
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"} return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@@ -1465,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config:
cover_url = config.get("cover_property") cover_url = config.get("cover_property")
cover_html = "" cover_html = ""
if cover_url: if cover_url:
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, list) and len(v) > 0: if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"): if url.startswith("http"):
@@ -1535,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
for p in pages: for p in pages:
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None start_val = end_val = None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"): if isinstance(v, str) and v.startswith("20"):
if "..." in v: if "..." in v:
parts = v.split("...") parts = v.split("...")
@@ -1708,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
for p in pages: for p in pages:
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None lat, lng = None, None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and "," in v: if isinstance(v, str) and "," in v:
parts = v.split(",") parts = v.split(",")
try: try:
@@ -1775,7 +1913,7 @@ def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: d
props = json.loads(p.get("property_values_json", "{}")) props = json.loads(p.get("property_values_json", "{}"))
group = None group = None
start_val = end_val = None start_val = end_val = None
for k, v in props.items(): for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"): if isinstance(v, str) and v.startswith("20"):
if "→" in v: if "→" in v:
parts = v.split("→") parts = v.split("→")
+82 -13
View File
@@ -217,7 +217,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
from app.routers.board import _load_shared_sidebar_pages from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"]) shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
return { sidebar = {
"workspace_name": ws, "workspace_initial": initial, "workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "", "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
@@ -937,7 +937,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
@router.get("/local-workspace", response_class=HTMLResponse) @router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None): async def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree. """Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?folder=ID is provided, shows that folder's contents with breadcrumb.
""" """
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
@@ -984,7 +984,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree") @router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None): async def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace. """Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children. If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root. Otherwise returns the full recursive tree from root.
""" """
@@ -992,16 +992,17 @@ async def local_workspace_tree(request: Request, folder: int = None):
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
if not ws_id: if not ws_id:
return {"tree": [], "breadcrumb": []} return {"tree": [], "breadcrumb": []}
uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
if folder: if folder:
# Show only this folder's children + build breadcrumb # Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id) children = _build_tree_children(conn, folder, ws_id, uid)
breadcrumb = _build_breadcrumb(conn, folder) breadcrumb = _build_breadcrumb(conn, folder)
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder} return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
else: else:
# Full tree from root # Full tree from root
roots = _build_tree_children(conn, None, ws_id) roots = _build_tree_children(conn, None, ws_id, uid)
return {"tree": roots, "breadcrumb": [], "current_folder": None} return {"tree": roots, "breadcrumb": [], "current_folder": None}
@@ -1028,11 +1029,11 @@ async def local_workspace_breadcrumb(request: Request, folder: int):
return {"breadcrumb": breadcrumb} return {"breadcrumb": breadcrumb}
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node.""" """Recursively build the tree of children for a node."""
if parent_id is None: if parent_id is None:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, " "SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages " "created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL " "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
@@ -1041,7 +1042,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
).fetchall() ).fetchall()
else: else:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, " "SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, " "(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages " "created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
@@ -1059,6 +1060,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
# Collect all page IDs to fetch tags in one query # Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows] all_ids = [r["id"] for r in rows]
tags_map = {} tags_map = {}
favorited_ids = set()
if all_ids: if all_ids:
placeholders = ",".join("?" for _ in all_ids) placeholders = ",".join("?" for _ in all_ids)
tag_rows = conn.execute( tag_rows = conn.execute(
@@ -1070,11 +1072,17 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
tags_map.setdefault(tr["page_id"], []).append({ tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"], "id": tr["id"], "name": tr["name"], "color": tr["color"],
}) })
if uid is not None:
fav_rows = conn.execute(
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
[uid, *all_ids],
).fetchall()
favorited_ids = {fr["page_id"] for fr in fav_rows}
tree = [] tree = []
for r in rows: for r in rows:
is_folder = r["parent_section"] == "Workspace" is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id) children = _build_tree_children(conn, r["id"], ws_id, uid)
# Compute size # Compute size
size = 0 size = 0
@@ -1094,6 +1102,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"type": "folder" if is_folder else "page", "type": "folder" if is_folder else "page",
"is_folder": is_folder, "is_folder": is_folder,
"content_format": r["content_format"] if not is_folder else None, "content_format": r["content_format"] if not is_folder else None,
"page_icon": r["page_icon"] or "",
"children": children, "children": children,
"has_children": len(children) > 0, "has_children": len(children) > 0,
"child_count": len(children), "child_count": len(children),
@@ -1104,6 +1113,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"author": author, "author": author,
"tags": tags_map.get(r["id"], []), "tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]), "is_shared": bool(r["is_shared"]),
"favorited": r["id"] in favorited_ids,
}) })
return tree return tree
@@ -1955,7 +1965,7 @@ async def update_account(request: Request):
@router.get("/api/sidebar/workspace-tree") @router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request): async def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment. """Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync. in the main content area to keep the sidebar in sync.
""" """
@@ -2022,7 +2032,7 @@ async def public_published_page(request: Request, slug: str):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, title, content, content_format, updated_at, created_at " "SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
"FROM pages WHERE publish_slug=? AND is_published=1", "FROM pages WHERE publish_slug=? AND is_published=1",
(slug,), (slug,),
).fetchone() ).fetchone()
@@ -2060,6 +2070,8 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
content_html=content_html, content_html=content_html,
updated_at=page.get("updated_at", ""), updated_at=page.get("updated_at", ""),
created_at=page.get("created_at", ""), created_at=page.get("created_at", ""),
cover_url=page.get("cover_url", ""),
page_icon=page.get("page_icon", ""),
) )
@@ -2169,9 +2181,66 @@ def _render_blocks_public(blocks: list) -> str:
alt = b.get("alt", "") alt = b.get("alt", "")
html_parts.append( html_parts.append(
f'<figure style="margin:16px 0;text-align:center;">' f'<figure style="margin:16px 0;text-align:center;">'
f'<img src="{src}" alt="{alt}" style="max-width:100%;border-radius:8px;">' f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
f'</figure>' f'</figure>'
) )
elif t == "video":
src = b.get("src", "")
if src:
html_parts.append(
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
f'<source src="{src}"></video>'
)
elif t == "audio":
src = b.get("src", "")
if src:
html_parts.append(
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
)
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = b.get("title") or url
desc = b.get("description") or ""
img = b.get("image") or ""
site = 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;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
html_parts.append(
f'<a href="{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 rgba(255,255,255,.12);border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
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", "")
emb = b.get("embed_type") or ""
if emb in ("inline_dbs", "collection"):
html_parts.append('<div>[Embedded content]</div>')
elif emb == "download":
html_parts.append(
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
)
elif emb == "pdf" and url:
html_parts.append(
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
)
elif url:
from app.services.embeds import embed_src
src = embed_src(url) or url
height = b.get("height") or 520
try:
height = int(height)
except (ValueError, TypeError):
height = 520
html_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>'
)
else: else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>') html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
return "\n".join(html_parts) return "\n".join(html_parts)
@@ -2227,7 +2296,7 @@ async def api_trash_page(page_id: int):
@router.post("/api/pages/{page_id:int}/convert-to-database") @router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request): async def api_convert_to_database(page_id: int, request: Request):
"""Convert a page into a full-page database (Notion-style). """Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property, Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'. and sets the page's content_format to 'collection'.
""" """
+2 -2
View File
@@ -74,10 +74,10 @@ async def export_pdf(page_id: int, request: Request):
try: try:
pdf_bytes = page_to_pdf_bytes(page) pdf_bytes = page_to_pdf_bytes(page)
except ImportError: except ImportError:
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("PDF export failed for page %s: %s", page_id, exc) logger.error("PDF export failed for page %s: %s", page_id, exc)
raise HTTPException(status_code=500, detail="PDF generation failed") raise HTTPException(status_code=500, detail="PDF generation failed") from exc
filename = _safe_filename(page, "pdf") filename = _safe_filename(page, "pdf")
headers = _download_header(filename, "application/pdf") headers = _download_header(filename, "application/pdf")
return Response(content=pdf_bytes, status_code=200, headers=headers) return Response(content=pdf_bytes, status_code=200, headers=headers)
+5 -4
View File
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request): def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401. """Return a per-user GiteaClient or raise 401.
Only returns a client if the user has personally connected their Gitea Only returns a client if the user has personally connected their Gitea
account (OAuth token). No fallback to admin token — each user must link account (OAuth token). No fallback to admin token — each user must link
their own Gitea account to see Gitea projects. their own Gitea account to see Gitea projects.
@@ -160,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
try: try:
labels = await gitea.get_labels(owner, repo) labels = await gitea.get_labels(owner, repo)
return {"labels": [ return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")} {"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
for l in labels for lbl in labels
]} ]}
except Exception as e: except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502) return JSONResponse({"error": str(e)}, status_code=502)
@@ -327,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
for label in labels: for label in labels:
name = label.get("name", "") name = label.get("name", "")
color = label.get("color", "#787774") color = label.get("color", "#787774")
if not name: continue if not name:
continue
existing = conn.execute( existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"]) "SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone() ).fetchone()
+58 -16
View File
@@ -59,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
elif content_format == "file": elif content_format == "file":
icon = "📄" icon = "📄"
fn = title.lower() fn = title.lower()
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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: else:
icon = "📝" icon = "📝"
@@ -69,6 +72,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"id": page_id, "id": page_id,
"title": title, "title": title,
"icon": icon, "icon": icon,
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)), "is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type, "source_type": source_type,
"source_label": _source_label(source_type, workspace), "source_label": _source_label(source_type, workspace),
@@ -84,6 +88,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"content_format": content_format, "content_format": content_format,
"favorited": bool(db_row.get("favorited", 0)), "favorited": bool(db_row.get("favorited", 0)),
"share_mode": db_row.get("share_mode", "private"), "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: 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: def _enrich_children(items: list) -> list:
@@ -124,7 +152,7 @@ def _enrich_children(items: list) -> list:
BASE_SELECT = ( BASE_SELECT = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, " "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"
) )
@@ -307,7 +335,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}") @router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request): async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion.""" """Return children of a local workspace item for tree expansion."""
uid = _get_user_id(request) _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
# Get the item to find its workspace # Get the item to find its workspace
item = conn.execute( item = conn.execute(
@@ -333,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
icon = "📁" if is_folder else "📄" icon = "📁" if is_folder else "📄"
fn = name.lower() fn = name.lower()
if not is_folder: if not is_folder:
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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: with get_conn() as conn:
child_count = conn.execute( child_count = conn.execute(
@@ -361,6 +392,8 @@ async def library_local_workspace_children(item_id: int, request: Request):
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}", "url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file", "content_format": r["content_format"] or "file",
"favorited": False, "favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "", "size_display": _format_size(r["size"]) if r["size"] else "",
}) })
@@ -441,9 +474,12 @@ async def library_local_workspace(
icon = "📁" if is_folder else "📄" icon = "📁" if is_folder else "📄"
fn = name.lower() fn = name.lower()
if not is_folder: if not is_folder:
if fn.endswith(".pdf"): icon = "📕" if fn.endswith(".pdf"):
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️" icon = "📕"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): 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 # Check for children
child_count = conn.execute( child_count = conn.execute(
@@ -469,6 +505,8 @@ async def library_local_workspace(
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}", "url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file", "content_format": r["content_format"] or "file",
"favorited": False, "favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "", "size_display": _format_size(r["size"]) if r["size"] else "",
}) })
@@ -476,10 +514,14 @@ async def library_local_workspace(
def _format_size(size_bytes): def _format_size(size_bytes):
if not size_bytes: return "" if not size_bytes:
if size_bytes < 1024: return f"{size_bytes} B" return ""
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB" if size_bytes < 1024:
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB" 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" return f"{size_bytes/1073741824:.1f} GB"
+1 -1
View File
@@ -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): async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON.""" """API: return my tasks as JSON."""
user = _get_current_user(request) 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: with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall() collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
+1 -1
View File
@@ -107,7 +107,7 @@ async def set_prefs(request: Request):
@router.get("/users/search") @router.get("/users/search")
async def search_users(request: Request, q: str = ""): async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions.""" """User autocomplete for @mentions."""
user = _current_user(request) _current_user(request)
q = (q or "").strip() q = (q or "").strip()
with get_conn() as conn: with get_conn() as conn:
if q: if q:
+1 -1
View File
@@ -201,7 +201,7 @@ async def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish") @router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request): async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug.""" """Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request) _require_auth(request)
with get_conn() as conn: with get_conn() as conn:
page = conn.execute( page = conn.execute(
+1 -1
View File
@@ -86,7 +86,7 @@ async def save_sidebar_config(request: Request):
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") raise HTTPException(status_code=400, detail="Invalid JSON body") from None
config = body.get("config") config = body.get("config")
if not config or not isinstance(config, dict): if not config or not isinstance(config, dict):
+1 -1
View File
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
return {"status": "ok", "webhook": result} return {"status": "ok", "webhook": result}
except Exception as e: except Exception as e:
logger.error("Failed to register webhook: %s", 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}") @router.get("/status/{owner}/{repo}")
+2 -2
View File
@@ -512,13 +512,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
try: try:
cur = conn.execute( conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)", "INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points), (sid, page_id, status_at_start, velocity_points),
) )
conn.commit() conn.commit()
except sqlite3.IntegrityError: 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"} return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
+22 -14
View File
@@ -32,6 +32,7 @@ MAX_ITERATIONS = 12
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? ») 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. - **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. - **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. - **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. - **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 »). - **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 »).
@@ -151,12 +152,21 @@ class AgentEngine:
self._persist_message(conversation_id, "user", objective) self._persist_message(conversation_id, "user", objective)
self._update_conversation(conversation_id, status="running") 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) tool_schema = self.tools.schema(scope)
final_text = None final_text = None
used_model = model or "" # peut être ajusté par un repli de modèle (404/410) used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try: try:
for step in range(settings.agent_max_iterations or MAX_ITERATIONS): for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget: if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"}) yield self._event("error", {"message": "Budget de tokens dépassé"})
break break
@@ -328,21 +338,11 @@ class AgentEngine:
# ── Auto-title (v4.14.0) ── # ── Auto-title (v4.14.0) ──
_DEFAULT_CONV_TITLES = {"", "new conversation", "nouvelle conversation", "new chat", "conversation"}
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None): async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
"""Rename a freshly created conversation with a descriptive title derived """Rename the conversation with a descriptive title derived from the
from its content. Only runs while the title is still a default placeholder, *latest* user request. Runs after every AI call so the history list
so a user-renamed conversation is never overwritten.""" always reflects the current topic and stays easy to browse."""
try: try:
with get_conn() as conn:
row = conn.execute(
"SELECT title FROM agent_conversations WHERE id=?", (conversation_id,),
).fetchone()
if not row:
return
if (row["title"] or "").strip().lower() not in self._DEFAULT_CONV_TITLES:
return
suggested = self._suggest_title(objective, final_text) suggested = self._suggest_title(objective, final_text)
if suggested: if suggested:
self._update_conversation(conversation_id, title=suggested[:80]) self._update_conversation(conversation_id, title=suggested[:80])
@@ -353,6 +353,14 @@ class AgentEngine:
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str: def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
"""Produce a short descriptive title from the user objective (offline-safe).""" """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() 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: if not text:
return "Conversation" return "Conversation"
low = text.lower() low = text.lower()
+194
View File
@@ -0,0 +1,194 @@
"""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, Loom,
CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, 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, urlparse
_PROVIDERS = (
# (host pattern, extra path check, to_embed(url, path, query) -> str | None)
)
def _q(params, key):
vals = params.get(key)
return vals[0] if vals else ""
def _embed_youtube(url: str, path: str, params) -> str | None:
m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path)
vid = m.group(2) if m else _q(params, "v")
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) -> 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) -> str | None:
m = re.search(r"/(embed/)?([0-9a-f]{32})", path)
if not m:
return None
return f"https://www.loom.com/embed/{m.group(2)}"
def _embed_figma(url: str, path: str, params) -> str | None:
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
return None
m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0])
if not m:
return None
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0]
def _embed_map(url: str, path: str, params) -> str | None:
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
return None
return f"https://www.google.com/maps?output=embed&q={url}"
def _embed_codepen(url: str, path: str, params) -> 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) -> str | None:
m = re.search(r"miro\.com/app/board/([^/?#]+)", 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) -> 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) -> str | None:
m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url)
if not m:
return None
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
def _embed_twitch(url: str, path: str, params) -> str | None:
if "twitch.tv" not in url:
return None
m = re.search(r"twitch\.tv/([^/?#]+)", url)
if not m:
return None
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
def _embed_twitter(url: str, path: str, params) -> str | None:
if "twitter.com" not in url and "x.com" not in url:
return None
m = re.search(r"/([^/?#]+)/status/(\d+)", url)
if not m:
return f"https://platform.twitter.com/embed/Tweet.html?url={url}"
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}"
def _embed_pinterest(url: str, path: str, params) -> str | None:
if "pinterest" not in url:
return None
return f"https://pinterest.com/pin/embed?url={url}"
def _embed_files(url: str, path: str, params) -> str | None:
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
return url
_HANDLERS = (
("youtube.com", _embed_youtube),
("youtu.be", _embed_youtube),
("vimeo.com", _embed_vimeo),
("loom.com", _embed_loom),
("figma.com", _embed_figma),
("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),
)
def embed_src(url: str) -> str | None:
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
if not url:
return None
url = url.strip()
u = urlparse(url if url.startswith("http") else "https://" + url)
if u.scheme not in ("http", "https"):
return None
(u.hostname or "").lower().replace("www.", "")
netloc = (u.netloc or "").lower()
params = parse_qs(u.query)
# Google Maps share links encode the query in the path (…&q=/maps/…)
encoded = url
for needle, handler in _HANDLERS:
if needle in netloc:
return handler(encoded, u.path, params)
return _embed_files(encoded, u.path, params)
_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."""
if not url:
return None
url = url.strip()
path = urlparse(url).path
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"
if url.startswith(("http://", "https://")):
return "iframe"
return None
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>'
)
+69 -2
View File
@@ -243,7 +243,8 @@ def _parse_table_at(lines: list[str], i: int, n: int):
rows.append(cells) rows.append(cells)
j += 1 j += 1
width = max(len(r) for r in rows) width = max(len(r) for r in rows)
pad = lambda r: r + [""] * (width - len(r)) def pad(r):
return r + [""] * (width - len(r))
align = (align + ["left"] * width)[:width] align = (align + ["left"] * width)[:width]
return ( return (
{ {
@@ -464,6 +465,11 @@ def _page_markdown_source(page: dict) -> str:
return "" 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 ═══════════════ # ═══════════════ Markdown ═══════════════
def blocks_to_markdown(blocks: list) -> str: def blocks_to_markdown(blocks: list) -> str:
@@ -508,6 +514,20 @@ def blocks_to_markdown(blocks: list) -> str:
src = b.get("src") or "" src = b.get("src") or ""
alt = (b.get("alt") or "").strip() or "image" alt = (b.get("alt") or "").strip() or "image"
out.append(f"![{alt}]({src})") out.append(f"![{alt}]({src})")
elif t == "video":
out.append(f"[Video]({b.get('src') or ''})")
elif t == "audio":
out.append(f"[Audio]({b.get('src') or ''})")
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = (b.get("title") or "").strip()
out.append(f"[{title or url}]({url})" if title else url)
elif t == "embed":
url = b.get("src") or ""
if b.get("embed_type") in ("pdf", "download", None, ""):
out.append(f"[{url}]({url})" if url else "[embed]")
else:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table": elif t == "table":
out.append(_table_to_markdown(b)) out.append(_table_to_markdown(b))
else: else:
@@ -609,7 +629,54 @@ def blocks_to_html(blocks: list) -> str:
elif t == "image": elif t == "image":
src = b.get("src") or "" src = b.get("src") or ""
alt = _text(b.get("alt")) alt = _text(b.get("alt"))
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>') 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 = 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": elif t == "table":
parts.append(_table_to_html(b)) parts.append(_table_to_html(b))
else: else:
+1 -1
View File
@@ -94,7 +94,7 @@ def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified): resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles)) placeholders = ",".join("?" * len(handles))
rows = conn.execute( rows = conn.execute(
"SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders, f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
handles, handles,
).fetchall() ).fetchall()
for row in rows: for row in rows:
+124
View File
@@ -0,0 +1,124 @@
"""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 logging
import re
from urllib.parse import urljoin, urlparse
logger = logging.getLogger(__name__)
_META_RE = re.compile(
r'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
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")
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
def _extract_og(html: str) -> dict:
text = html[:400_000] # only scan the beginning — that's where <head> lives
props: dict[str, str] = {}
for m in _META_RE.finditer(text):
m.groups()
content = ""
prop = ""
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
if prop == "":
prop = s
else:
content = s
break
if prop:
props[prop.lower()] = 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, raw_title: str) -> str:
t = _pick(props, _OG_TITLE)
if t:
return t
m = re.search(r"<title[^>]*>(.*?)</title>", raw_title[:200_000], re.I | re.S)
return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc
def _site_name(url: str) -> str:
host = urlparse(url).netloc.replace("www.", "")
return host.split(".")[0].capitalize() if host else ""
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors."""
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.10 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) 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
import html as htmlmod
body = resp.text
props = _extract_og(body)
props["_url"] = src
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:
if not u:
return ""
return urljoin(src, htmlmod.unescape(u))
favicon = ""
fm = re.search(r"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
if not fm:
fm = re.search(r"<link[^>]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I)
if fm:
favicon = abs_url(fm.group(1))
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,
}
+77 -3
View File
@@ -479,17 +479,90 @@ class UpdatePage(Tool):
class WriteBlocks(Tool): class WriteBlocks(Tool):
name = "write_blocks" name = "write_blocks"
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs " description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
"et/ou renomme son titre.") "et/ou renomme son titre. Chaque bloc est un objet JSON "
"{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : "
"paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, "
"numbered_list, to_do, quote, divider, toggle, callout, code. "
"Utilise `type: \"code\"` (avec `language`) pour un script ou du code "
"multi-lignes, `heading_1`/`heading_2`/… pour les titres. "
"Le champ contenant le texte s'appelle `content`, PAS `text`.")
parameters = { parameters = {
"type": "object", "type": "object",
"properties": { "properties": {
"page_id": {"type": "integer", "description": "id du document (page éditeur)"}, "page_id": {"type": "integer", "description": "id du document (page éditeur)"},
"blocks": {"type": "array", "description": "nouveau contenu en blocs (remplace le contenu)"}, "blocks": {"type": "array",
"description": "nouveau contenu en blocs (remplace le contenu). "
"Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, "
"{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"},
"title": {"type": "string", "description": "nouveau titre (optionnel)"}, "title": {"type": "string", "description": "nouveau titre (optionnel)"},
}, },
"required": ["page_id"], "required": ["page_id"],
} }
_VALID_BLOCK_TYPES = frozenset({
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
"callout", "table_of_contents", "math", "columns", "divider",
"code", "table", "button", "image", "embed", "bookmark",
"video", "audio", "meeting",
})
@classmethod
def _normalize_blocks(cls, blocks: list) -> list:
"""Normalize blocks coming from the LLM so the editor can render them.
Common LLM mistakes handled:
- ``text`` instead of ``content``
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
- ``bullet`` / ``list`` instead of ``bulleted_list``
- ``numbered`` instead of ``numbered_list``
- ``check`` / ``checkbox`` instead of ``to_do``
- ``code_block`` instead of ``code``
- ``h1`` … ``h6`` shorthand
- Missing ``id`` fields (editor assigns them on load but we add them
to keep the JSON self-contained)
"""
import uuid
_TYPE_MAP = {
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
"h6": "heading_4",
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
"numbered": "numbered_list", "numbered_list": "numbered_list",
"check": "to_do", "checkbox": "to_do", "task": "to_do",
"code_block": "code",
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
}
out = []
for block in (blocks or []):
if not isinstance(block, dict):
continue
b = dict(block)
# --- normalize type ---
raw_type = (b.get("type") or "paragraph").strip().lower()
btype = _TYPE_MAP.get(raw_type, raw_type)
if btype not in cls._VALID_BLOCK_TYPES:
btype = "paragraph"
b["type"] = btype
# --- normalize content field (LLM often sends "text" instead of "content") ---
if "content" not in b and "text" in b:
b["content"] = b.pop("text")
elif "content" not in b:
# Try other common fields
for alt in ("value", "body", "source"):
if alt in b:
b["content"] = b.pop(alt)
break
else:
b.setdefault("content", "")
# --- generate id if missing ---
if not b.get("id"):
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
out.append(b)
return out
async def execute(self, args, *, user_id=None) -> ToolResult: async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id") pid = args.get("page_id")
has_blocks = "blocks" in args has_blocks = "blocks" in args
@@ -506,9 +579,10 @@ class WriteBlocks(Tool):
"title": page["title"]} "title": page["title"]}
final_title = new_title or page["title"] final_title = new_title or page["title"]
if has_blocks: if has_blocks:
normalized = self._normalize_blocks(args["blocks"])
conn.execute( conn.execute(
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(args["blocks"], ensure_ascii=False), final_title, pid), (json.dumps(normalized, ensure_ascii=False), final_title, pid),
) )
else: else:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+87
View File
@@ -0,0 +1,87 @@
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
window (default 30 days, matching the UI copy in ``trash.html``), then are
permanently deleted together with their child pages and orphaned versions.
The purge is run from a background scheduler in ``app.main`` so it is fully
automatic (no per-request cost).
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from app.db import get_conn
logger = logging.getLogger(__name__)
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
def _parse_dt(value: str) -> datetime | None:
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
if not value:
return None
try:
s = value[:19].replace("T", " ")
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
dt = datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
if not ISO_RE.match(value):
return None
return dt
def purge_expired(days: int = 30) -> dict:
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
Children are re-parented to their grandparent before deletion in order
to avoid silently dropping whole sub-trees; deleted pages are removed
together with their ``page_versions`` (FK cascade).
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
for row in rows:
dt = _parse_dt(row["deleted_at"])
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
"WHERE parent_id=?",
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
purged.append(page_id)
conn.commit()
if purged:
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
return {"purged": purged, "count": len(purged)}
async def trash_purge_scheduler(interval_hours: int = 24):
"""Background loop purging the trash once a day (idempotent, cheap when
there is nothing expired)."""
import asyncio
# Run once shortly after startup, then on the configured interval.
await asyncio.sleep(5)
while True:
try:
purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600)
+290
View File
@@ -0,0 +1,290 @@
{###############################################################################
_ctx_menu.html — LE menu contextuel UNIQUE de FlowDeck
═══════════════════════════════════════════════════════════════════════════
Inclus par library.html ET local_workspace.html (les 2 seules pages du
contexte fichier). Ce partial est LE SEUL menu : son markup décrit l'UNION
des options des deux pages d'origine. Les exécutions sont déléguées par
chaque page au travers d'un store Alpine enregistré PLUS BAS (IIFE + défense
d'exécution unique → include 2 fois = store 1 fois).
Chaque item se masque quand le handler correspondant n'existe pas sur la
page (x-show="$store.fdCtx.has('key')") → menu structurellement identique,
avec la capacité respective de la page au clic.
Handlers attendus (tous optionnels) :
open, openTab, peek, folder, rename, setIcon, duplicate, link, move,
fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdCtxStore) return;
if (window.Alpine) window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport. */
var place = function () {
var el = document.querySelector('.fd-ctx-menu');
var w = el ? el.offsetWidth : 240;
var h = el ? el.offsetHeight : 320;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = cx, ny = cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(place);
else setTimeout(place, 0);
},
close() {
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
if (!icon) return;
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
});
});
})();
</script>
<style>
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
.fd-ctx-menu .menu-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;transition:background .1s;white-space:nowrap;}
.fd-ctx-menu .menu-item:hover{background:var(--bg-hover);}
.fd-ctx-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.fd-ctx-menu .menu-danger{color:var(--danger);}
.fd-ctx-menu .menu-shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);padding-left:16px;}
.fd-ctx-menu .more-sep{height:1px;background:var(--border);margin:4px 6px;}
.fd-ctx-menu .ctx-label{color:var(--text-dim);font-size:11px;cursor:default;text-transform:uppercase;letter-spacing:.04em;}
.fd-ctx-menu .ctx-label:hover{background:transparent;}
.fd-ctx-menu .ctx-empty{color:var(--text-dim);font-size:11px;cursor:default;}
.fd-ctx-menu .ctx-empty:hover{background:transparent;}
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;}
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
.fd-ctx-menu .ctx-swatch.selected{border-color:var(--text-primary);}
.fd-ctx-menu .ctx-input{flex:1;min-width:0;font-size:12px;padding:3px 6px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);outline:none;}
.fd-ctx-menu .ctx-input:focus{border-color:var(--accent);}
.fd-ctx-menu .ctx-btn{padding:3px 10px;font-size:12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;}
.fd-ctx-menu .ctx-icons{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:2px 4px 4px;}
.fd-ctx-menu .ctx-icon{width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:transparent;border:none;border-radius:4px;cursor:pointer;}
.fd-ctx-menu .ctx-icon:hover{background:var(--bg-hover);}
</style>
<div class="more-menu fd-ctx-menu" x-show="$store.fdCtx.open" x-cloak
@click.outside="$store.fdCtx.close()"
@keydown.escape.window="$store.fdCtx.close()"
x-transition
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px' }">
{# ── Open ── #}
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span style="flex:1;">Open</span>
</div>
{# ── Open in new tab ── #}
<div class="menu-item" x-show="$store.fdCtx.has('openTab')" @click="$store.fdCtx.run('openTab')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span style="flex:1;">Open in new tab</span><span class="menu-shortcut">Ctrl+⇧+↩</span>
</div>
{# ── Open in side peek ── #}
<div class="menu-item" x-show="$store.fdCtx.has('peek')" @click="$store.fdCtx.run('peek')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span style="flex:1;">Open in side peek</span><span class="menu-shortcut">Alt+Click</span>
</div>
{# ── Open folder (workspace, dossiers) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('folder')" @click="$store.fdCtx.run('folder')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
<span style="flex:1;">Open folder</span>
</div>
<div class="more-sep" x-show="$store.fdCtx.has('rename') || $store.fdCtx.has('setIcon') || $store.fdCtx.has('duplicate') || $store.fdCtx.has('link') || $store.fdCtx.has('move')"></div>
{# ── Rename ── #}
<div class="menu-item" x-show="$store.fdCtx.has('rename')" @click="$store.fdCtx.run('rename')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
</div>
{# ── Edit icon ── #}
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.iconOpen = !$store.fdCtx.iconOpen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
<span style="flex:1;">Edit icon</span><span class="ctx-caret" x-text="$store.fdCtx.iconOpen ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.iconOpen" class="ctx-sub">
<div class="ctx-icons">
<template x-for="ic in $store.fdCtx.iconChoices" :key="'ic'+ic">
<button type="button" class="ctx-icon" @click.stop="$store.fdCtx.setIcon(ic)" x-text="ic"></button>
</template>
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxIconInput" placeholder="Custom emoji…" @click.stop @keydown.enter.prevent="$store.fdCtx.setIcon($refs.ctxIconInput.value)">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.setIcon($refs.ctxIconInput.value)">Set</button>
</div>
</div>
{# ── Duplicate ── #}
<div class="menu-item" x-show="$store.fdCtx.has('duplicate')" @click="$store.fdCtx.run('duplicate')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<span style="flex:1;">Duplicate</span>
</div>
{# ── Copy link ── #}
<div class="menu-item" x-show="$store.fdCtx.has('link')" @click="$store.fdCtx.run('link')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
<span style="flex:1;">Copy link</span>
</div>
{# ── Move to ── #}
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
<span style="flex:1;">Move to</span><span class="menu-shortcut">Ctrl+⇧+P</span>
</div>
{# ── Favorites ── #}
<div class="menu-item" x-show="$store.fdCtx.has('fav')" @click="$store.fdCtx.run('fav')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<span style="flex:1;" x-text="$store.fdCtx.node && $store.fdCtx.node.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div>
{# ── Remove from Recents ── #}
<div class="menu-item" x-show="$store.fdCtx.has('recent')" @click="$store.fdCtx.run('recent')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<span style="flex:1;">Remove from Recents</span>
</div>
{# ── Tags (workspace) ── #}
<div class="more-sep" x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')"></div>
<div x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')">
<div class="menu-item ctx-label"><span style="flex:1;">Tags</span></div>
<template x-for="t in ($store.fdCtx.node && $store.fdCtx.node.tags) || []" :key="'ct'+t.id">
<div class="menu-item" @click.stop="$store.fdCtx.removeTag(t.id)">
<span style="display:flex;align-items:center;gap:8px;flex:1;overflow:hidden;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name" style="overflow:hidden;text-overflow:ellipsis;"></span></span>
<span class="ctx-x" title="Remove tag">×</span>
</div>
</template>
<div class="menu-item ctx-empty" x-show="!($store.fdCtx.node && ($store.fdCtx.node.tags||[]).length)">No tags</div>
<div class="menu-item" x-show="$store.fdCtx.avail().length" @click.stop="$store.fdCtx.tagExistingOpen = !$store.fdCtx.tagExistingOpen">
<span style="flex:1;">Add existing tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagExistingOpen ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.tagExistingOpen" class="ctx-sub">
<template x-for="t in $store.fdCtx.avail()" :key="'ca'+t.id">
<div class="menu-item" @click.stop="$store.fdCtx.addExistingTag(t)">
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name"></span></span>
</div>
</template>
</div>
<div class="menu-item" @click.stop="$store.fdCtx.tagAdding = !$store.fdCtx.tagAdding">
<span style="flex:1;">+ New tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagAdding ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.tagAdding" class="ctx-sub">
<div class="ctx-colors">
<template x-for="c in $store.fdCtx.tagColors" :key="'cc'+c">
<span class="ctx-swatch" :class="{ selected: $store.fdCtx.newTagColor === c }" :style="{ background: c }" @click.stop="$store.fdCtx.newTagColor = c"></span>
</template>
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
</div>
</div>
</div>
{# ── Delete (danger) ── #}
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="$store.fdCtx.run('delete')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
<span style="flex:1;">Move to Trash</span><span class="menu-shortcut">Del</span>
</div>
</div>
+164 -18
View File
@@ -1,4 +1,8 @@
<div class="page-editor-wrapper" x-data="editorState()"> <div
class="page-editor-wrapper"
x-data="editorState()"
x-init="loadCoverIcon()"
>
<!-- Share/More dialogs (in content area, triggered by header buttons) --> <!-- Share/More dialogs (in content area, triggered by header buttons) -->
<!-- ═══════════ Share / Publish Dialog ═══════════ --> <!-- ═══════════ Share / Publish Dialog ═══════════ -->
@@ -250,6 +254,46 @@
<div class="sd-sep"></div> <div class="sd-sep"></div>
<!-- v5.5.0: Cover & Icon -->
<div class="sd-section">
<div class="sd-section-title">{{ fd_icon('image',14) }} Cover & Icon</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="sd-btn-secondary" @click="toggleCover()">
<span x-show="!coverOpen">{{ fd_icon('image',14) }} Cover</span>
<span x-show="coverOpen">{{ fd_icon('x',14) }} Hide</span>
</button>
<button class="sd-btn-secondary" @click="toggleIcon()">
<span x-show="!iconOpen">{{ fd_icon('sparkles',14) }} Icon</span>
<span x-show="iconOpen">{{ fd_icon('x',14) }} Hide</span>
</button>
</div>
<div x-show="coverOpen" style="margin-top:12px;" x-transition>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<input type="text" class="sd-input" x-model="coverUrl" placeholder="Cover image URL" style="flex:1;min-width:200px;">
<button class="sd-btn-primary" @click="setCoverUrl()" :disabled="coverLoading" x-show="!coverLoading">Set</button>
<button class="sd-btn-primary" :disabled="coverLoading" x-show="coverLoading">Saving…</button>
<label class="sd-btn-secondary" style="display:flex;align-items:center;gap:6px;">
{{ fd_icon('upload',14) }} Upload
<input type="file" accept="image/*" @change="uploadCover($event)" style="display:none;">
</label>
<button class="sd-btn-danger" @click="removeCover()" x-show="coverUrl">Remove</button>
</div>
<div x-show="coverUrl" style="max-width:320px;border-radius:8px;overflow:hidden;border:1px solid var(--border);">
<img :src="coverUrl" style="width:100%;height:auto;display:block;">
</div>
</div>
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<template x-for="ic in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ic">
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
</template>
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
</div>
</div>
</div>
<div class="sd-sep"></div>
<!-- Footer --> <!-- Footer -->
<div class="sd-footer"> <div class="sd-footer">
<a href="#" class="sd-footer-link" @click.prevent <a href="#" class="sd-footer-link" @click.prevent
@@ -395,6 +439,15 @@
@click.outside="!_justOpened && (moreOpen = false)" @click.outside="!_justOpened && (moreOpen = false)"
x-transition x-transition
> >
<div class="more-menu-item" @click="toggleBacklinks">
{{ fd_icon("link-2",14) }} Linked from
</div>
<div class="more-menu-item" @click="toggleVersions">
{{ fd_icon("history",14) }} Version history
</div>
<div class="more-menu-item" @click="toggleImport">
{{ fd_icon("download",14) }} Import
</div>
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div> <div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
<template x-if="exportOpen"> <template x-if="exportOpen">
<div class="more-menu-sub"> <div class="more-menu-sub">
@@ -439,21 +492,23 @@
</div> </div>
<!-- ═══════════ Page Content ═══════════ --> <!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area"> <div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
<div class="page-title-block"> <img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
<span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span> <div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
<div </div>
class="page-title-input" <div class="page-title-block">
contenteditable="true" <span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
id="_titleEl" <div
data-placeholder="New Page" class="page-title-input"
@input="dirty=true;save()" contenteditable="true"
@keydown.enter.prevent="focusBlock()" id="_titleEl"
@paste.prevent="pastePlain($event)" data-placeholder="New Page"
spellcheck="false" @input="dirty=true;save()"
> @keydown.enter.prevent="focusBlock()"
{{ page.title }} @paste.prevent="pastePlain($event)"
</div> spellcheck="false"
>
{{ page.title }}
</div> </div>
</div> </div>
@@ -669,6 +724,97 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
<div class="versions-popover"
x-show="versionsOpen"
@click.outside="versionsOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
<span>{{ fd_icon('history',14) }} Version history</span>
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
</div>
<div style="max-height:420px;overflow-y:auto;">
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
<template x-if="!versionsLoading && versionsList.length === 0">
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
</template>
<template x-for="v in versionsList" :key="v.id">
<div class="version-item"
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
@click="restoreVersion(v.id)">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
</div>
</template>
</div>
</div>
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
<div class="backlinks-popover"
x-show="backlinksOpen"
@click.outside="backlinksOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
<span>{{ fd_icon('link-2',14) }} Linked from</span>
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
</div>
<div style="max-height:420px;overflow-y:auto;">
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
<template x-if="!backlinksLoading && backlinksList.length === 0">
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
</template>
</div>
</div>
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
<div class="import-modal"
x-show="importOpen"
@click.self="importOpen = false"
x-transition.opacity
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
<button class="flowdeck-modal-close" @click="importOpen = false">&times;</button>
</div>
<div style="padding:20px;overflow-y:auto;flex:1;">
<div style="display:flex;flex-direction:column;gap:16px;">
<!-- Markdown text import -->
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import as new page</button>
</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:16px;">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
<div x-show="importFile" style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
</div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+224 -11
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data"> <script type="application/json" id="page-data">
{{ page_data | tojson }} {{ page_data | tojson }}
</script> </script>
<script> <script data-cfasync="false">
/* eslint-disable */ /* eslint-disable */
const CMDS=[{name:'BASIC',items:[ const CMDS=[{name:'BASIC',items:[
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'}, {id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
@@ -14,7 +14,11 @@
{id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'}, {id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'},
{id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'}, {id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'},
]}, ]},
{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]}, {name:'MEDIA',items:[
{id:'image',name:'Image',icon:'🖼️'},{id:'embed',name:'Embed',icon:'🔗'},
{id:'bookmark',name:'Bookmark',icon:'🔖'},{id:'video',name:'Video',icon:'🎬'},
{id:'audio',name:'Audio',icon:'🎵'},
]},
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}, {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}]; {name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
@@ -199,7 +203,7 @@
} }
// ── Block type shortcuts (shown on the right of each slash item) ── // ── Block type shortcuts (shown on the right of each slash item) ──
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'}; const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
// ── Hover preview card (matches Notion's visual hint) ── // ── Hover preview card (matches Notion's visual hint) ──
function _previewHtml(blockId){ function _previewHtml(blockId){
@@ -222,6 +226,10 @@
case 'code': return p('<pre style="background:#111;padding:10px;border-radius:6px;font-size:12px"><code>const x = 1;</code></pre>'); case 'code': return p('<pre style="background:#111;padding:10px;border-radius:6px;font-size:12px"><code>const x = 1;</code></pre>');
case 'table': return p('<div style="font-size:12px"><table style="width:100%;border-collapse:collapse"><tr><td style="border:1px solid var(--border);padding:4px">A</td><td style="border:1px solid var(--border);padding:4px">B</td></tr><tr><td style="border:1px solid var(--border);padding:4px">1</td><td style="border:1px solid var(--border);padding:4px">2</td></tr></table></div>'); case 'table': return p('<div style="font-size:12px"><table style="width:100%;border-collapse:collapse"><tr><td style="border:1px solid var(--border);padding:4px">A</td><td style="border:1px solid var(--border);padding:4px">B</td></tr><tr><td style="border:1px solid var(--border);padding:4px">1</td><td style="border:1px solid var(--border);padding:4px">2</td></tr></table></div>');
case 'image': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">[Image]</div>'); case 'image': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">[Image]</div>');
case 'embed': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🔗 Embed (YouTube, Figma, Maps…)</div>');
case 'bookmark': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🔖 Bookmark card with preview</div>');
case 'video': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎬 Inline video player</div>');
case 'audio': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center;color:var(--text-dim);font-size:12px">🎵 Inline audio player</div>');
case 'database': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px">📊 Inline database table</div>'); case 'database': return p('<div style="border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px">📊 Inline database table</div>');
default: return p('<div style="font-size:13px;color:var(--text-dim)">Text block</div>'); default: return p('<div style="font-size:13px;color:var(--text-dim)">Text block</div>');
} }
@@ -408,10 +416,20 @@
if(b.type==='table')return renderTableBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx);
if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`; if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
if(b.type==='button')return renderAutomationButton(b,idx); if(b.type==='button')return renderAutomationButton(b,idx);
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`; if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" class="fd-img" data-full="${b.src||''}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;cursor:zoom-in;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'" onclick="E._openLightbox(this)"></div></div>`;
if(b.type==='table_of_contents')return renderTOC(b); if(b.type==='video'){
if(b.type==='math')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`; const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video"><video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:0 auto;"><source src="${src}"></video></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setVideoSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎬</div><div style="font-size:14px;color:var(--text-dim);">Click to add video URL</div></div></div>`;
if(b.type==='columns')return renderColumnsBlock(b); }
if(b.type==='audio'){
const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-audio"><audio controls preload="metadata" style="width:100%;"><source src="${src}"></audio></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-audio" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setAudioSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎵</div><div style="font-size:14px;color:var(--text-dim);">Click to add audio URL</div></div></div>`;
}
if(b.type==='bookmark'){
var url=b.url||b.src||'';var title=esc(b.title||url);var desc=esc(b.description||'');var img=b.image||'';var site=esc(b.site_name||'');
var imgHtml=img?'<img src="'+img+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
var descHtml=desc?'<div style="font-size:13px;color:var(--text-dim);margin-top:4px;">'+desc+'</div>':'';
var siteHtml=site?'<div style="font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">'+site+'</div>':'';
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="'+url+'" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">'+title+'</div>'+descHtml+siteHtml+'</div>'+imgHtml+'</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
}
if(b.type==='embed'){ if(b.type==='embed'){
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`; if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
if(b.embed_type==='collection')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`; if(b.embed_type==='collection')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`;
@@ -420,6 +438,13 @@
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB'; var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`; return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
} }
var url=b.src||'';if(url){
var src=url;
try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){}
var height=b.height||520;
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
}
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
} }
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...'); const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
let inner=''; let inner='';
@@ -865,6 +890,18 @@
_commentSel:{range:null,bid:null,start:0,end:0}, _commentSel:{range:null,bid:null,start:0,end:0},
_notifiedMentions:{}, _notifiedMentions:{},
undoStack:[],redoStack:[],_histCap:100,_histLock:false, undoStack:[],redoStack:[],_histCap:100,_histLock:false,
// ── Cover & icon (v5.10+) ──
coverUrl:'',
iconOpen:false,
iconValue:'',
// ── Import ──
importOpen:false,importText:'',importFile:null,
// ── DB Templates ──
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
// ── Version history ──
versionsOpen:false,versionsLoading:false,versionsList:[],
// ── Backlinks ──
backlinksOpen:false,backlinksLoading:false,backlinksList:[],
get timeAgo() { get timeAgo() {
if (!this.updatedAt) return 'just now'; if (!this.updatedAt) return 'just now';
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000); var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
@@ -985,10 +1022,182 @@
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}} if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
else{el.innerHTML='<span>'+esc(tex)+'</span>';} else{el.innerHTML='<span>'+esc(tex)+'</span>';}
}, },
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}if(type==='embed'){b.embed_type='';b.src='';b.height=520;}if(type==='bookmark'){b.url='';b.title='';b.description='';b.image='';b.site_name='';}if(type==='video'){b.src='';b.title='';}if(type==='audio'){b.src='';b.title='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;}, addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
addAfter(idx,type){return this.addAt(idx+1,type);}, addAfter(idx,type){return this.addAt(idx+1,type);},
removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
// ── v5.4.0/v5.5.0: Media block URL setters (click empty block → prompt) ──
_mediaPrompt(bid, field, label){
const idx=this.getIdx(bid); if(idx<0)return;
const b=this.blocks[idx]; if(!b)return;
const cur=b[field]||''; const u=prompt(label+' (URL):', cur);
if(u===null)return;
if(u.trim()===cur.trim())return;
this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync();
},
setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');},
setBookmarkUrl(bid){
const idx=this.getIdx(bid); if(idx<0)return;
const b=this.blocks[idx]; if(!b)return;
const cur=b.url||''; const u=prompt('Bookmark URL:', cur);
if(u===null)return;
if(u.trim()===cur.trim())return;
this.sync();this.pushHistory();b.url=u.trim();
this._fetchBookmarkPreview(bid, u.trim());
},
async _fetchBookmarkPreview(bid, url){
try{
const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
const d=await r.json();
if(d&&d.ok){
const idx=this.getIdx(bid); if(idx<0)return;
const b=this.blocks[idx];
if(d.title)b.title=d.title;
if(d.description)b.description=d.description;
if(d.image)b.image=d.image;
if(d.site_name)b.site_name=d.site_name;
this.render();this.dirty=true;this.autoSave();_rtSync();
}
}catch(e){}
},
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
// ── v5.5.0: Image lightbox ──
_openLightbox(imgEl){
const full=imgEl.dataset.full||imgEl.src;
if(!full)return;
const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
ov.innerHTML='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
document.body.appendChild(ov);
const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);};
const kd=(e)=>{if(e.key==='Escape')close();};
document.addEventListener('keydown',kd);
ov.addEventListener('click',e=>{if(e.target===ov)close();});
},
// ── v5.4.0: Version history UI ──
versionsOpen:false,versionsLoading:false,versionsList:[],
async toggleVersions(){
this.versionsOpen=!this.versionsOpen;
if(this.versionsOpen){await this.loadVersions();}
},
async loadVersions(){
this.versionsLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/versions');
const d=await r.json();
this.versionsList=d.versions||[];
}catch(e){this.versionsList=[];}
this.versionsLoading=false;
},
async restoreVersion(vid){
if(!confirm('Restore this version? Current content will be replaced.'))return;
try{
const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
const d=await r.json();
if(d.status==='ok'){
this.showToast('Version restored');this.versionsOpen=false;
setTimeout(()=>window.location.reload(),300);
}
}catch(e){this.showToast('Restore failed','error');}
},
// ── v5.4.0: Backlinks ──
backlinksOpen:false,backlinksLoading:false,backlinksList:[],
async toggleBacklinks(){
this.backlinksOpen=!this.backlinksOpen;
if(this.backlinksOpen){await this.loadBacklinks();}
},
async loadBacklinks(){
this.backlinksLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
const d=await r.json();
this.backlinksList=d.backlinks||[];
}catch(e){this.backlinksList=[];}
this.backlinksLoading=false;
},
// ── v5.5.0: Cover & Icon ──
async loadCoverIcon(){
try{
const r=await fetch('/board/api/pages/'+this.pid);
const d=await r.json();
this.coverUrl=d.cover_url||'';
this.iconValue=d.page_icon||'';
}catch(e){}
},
toggleCover(){this.coverOpen=!this.coverOpen;if(this.coverOpen&&!this.coverUrl){this.loadCoverIcon();}},
async setCoverUrl(){
const u=prompt('Cover image URL:', this.coverUrl);
if(u===null)return;
this.coverLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
const d=await r.json();
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');}
}catch(e){this.showToast('Cover update failed','error');}
this.coverLoading=false;
},
async uploadCover(e){
const f=e.target.files?.[0];if(!f)return;
this.coverLoading=true;
const fd=new FormData();fd.append('file',f);
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const d=await r.json();
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');}
}catch(e){this.showToast('Cover upload failed','error');}
this.coverLoading=false;
e.target.value='';
},
async removeCover(){
if(!confirm('Remove cover?'))return;
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
const d=await r.json();
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
async setIcon(icon){
this.iconValue=icon;
try{
const r=await fetch('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
const d=await r.json();
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
}catch(e){this.showToast('Icon update failed','error');}
},
// ── v5.4.0: Import ──
importOpen:false,importFile:null,importText:'',
toggleImport(){this.importOpen=!this.importOpen;},
handleImportFile(e){
const f=e.target.files?.[0];if(!f)return;
this.importFile=f;
const reader=new FileReader();
reader.onload=()=>{this.importText=reader.result;};
reader.readAsText(f);
},
async doImport(){
if(!this.importText.trim()&&!this.importFile){this.showToast('Nothing to import');return;}
if(this.importFile){
const fd=new FormData();fd.append('file',this.importFile);
try{
const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const d=await r.json();
if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';}
}catch(e){this.showToast('Import failed','error');}
}else{
try{
const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
const d=await r.json();
if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);}
}catch(e){this.showToast('Import failed','error');}
}
},
// ── v5.10.0: Undo / Redo ── // ── v5.10.0: Undo / Redo ──
pushHistory(){if(this._histLock)return;this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));if(this.undoStack.length>this._histCap)this.undoStack.shift();this.redoStack.length=0;}, pushHistory(){if(this._histLock)return;this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));if(this.undoStack.length>this._histCap)this.undoStack.shift();this.redoStack.length=0;},
@@ -1479,6 +1688,10 @@ applyAIBlocks(text){
if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;} if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;}
const block=this.blocks[idx];this.sync();this.pushHistory();block.content=''; const block=this.blocks[idx];this.sync();this.pushHistory();block.content='';
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');} if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
else if(id==='embed'){block.type='embed';block.embed_type='';block.src='';block.height=520;}
else if(id==='bookmark'){block.type='bookmark';block.url='';block.title='';block.description='';block.image='';block.site_name='';}
else if(id==='video'){block.type='video';block.src='';block.title='';}
else if(id==='audio'){block.type='audio';block.src='';block.title='';}
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}} else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
this.render();this.dirty=true;this.autoSave();_rtSync(); this.render();this.dirty=true;this.autoSave();_rtSync();
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);} if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
+313 -12
View File
@@ -37,6 +37,15 @@
.fd-ap-hist-item{padding:8px 11px;border-radius:9px;cursor:pointer;color:var(--text,#e0e0e0);font-size:13px;border:1px solid transparent;margin-bottom:2px} .fd-ap-hist-item{padding:8px 11px;border-radius:9px;cursor:pointer;color:var(--text,#e0e0e0);font-size:13px;border:1px solid transparent;margin-bottom:2px}
.fd-ap-hist-item:hover,.fd-ap-hist-item.active{background:var(--bg-hover,#262626);border-color:var(--border,rgba(255,255,255,.08))} .fd-ap-hist-item:hover,.fd-ap-hist-item.active{background:var(--bg-hover,#262626);border-color:var(--border,rgba(255,255,255,.08))}
/* History hover / long-press preview */
.fd-ap-preview{position:fixed;width:290px;max-width:calc(100vw - 24px);background:var(--bg-modal,#1c1c1c);border:1px solid var(--border,rgba(255,255,255,.16));border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,.6);z-index:1990;padding:11px 13px;font-size:12px;color:var(--text,#e6e6e6);display:none;pointer-events:auto}
.fd-ap-preview.show{display:block}
.fd-ap-preview-title{font-weight:600;font-size:12.5px;margin-bottom:2px;color:var(--text,#fff);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-ap-preview-meta{font-size:10px;color:var(--text-dim,#999);margin-bottom:8px}
.fd-ap-preview-empty{font-size:11px;color:var(--text-dim,#aaa);padding:4px 0}
.fd-ap-preview-label{font-size:9.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:7px 0 3px}
.fd-ap-preview-txt{white-space:pre-wrap;word-break:break-word;line-height:1.55;max-height:72px;overflow:hidden;color:var(--text-secondary,#d0d0d0)}
/* Empty / welcome state */ /* Empty / welcome state */
.fd-ap-empty{padding:6px 2px 4px} .fd-ap-empty{padding:6px 2px 4px}
.fd-ap-empty-title{font-size:14px;font-weight:600;color:var(--text,#eee);margin-bottom:2px} .fd-ap-empty-title{font-size:14px;font-weight:600;color:var(--text,#eee);margin-bottom:2px}
@@ -66,6 +75,29 @@
.fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px} .fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px}
.fd-ap-notice{color:var(--text-secondary,#ccc);font-size:12px;padding:7px 10px;margin:2px 0 8px;border-radius:9px;background:rgba(128,128,128,.12);border:1px solid var(--border,rgba(255,255,255,.12))} .fd-ap-notice{color:var(--text-secondary,#ccc);font-size:12px;padding:7px 10px;margin:2px 0 8px;border-radius:9px;background:rgba(128,128,128,.12);border:1px solid var(--border,rgba(255,255,255,.12))}
/* Steps accordion (Notion-style reasoning + actions audit trail) */
.fd-steps{margin:0 0 8px;border:1px solid var(--border,rgba(255,255,255,.11));border-radius:10px;background:rgba(255,255,255,.03);overflow:hidden}
.fd-steps-head{display:flex;align-items:center;gap:7px;width:100%;padding:7px 11px;background:transparent;border:none;color:var(--text-secondary,#ccc);font-size:12px;cursor:pointer;text-align:left}
.fd-steps-head:hover{background:rgba(255,255,255,.05)}
.fd-steps-chev{display:inline-block;font-size:9px;color:var(--text-dim,#999);transition:transform .15s;width:12px;flex-shrink:0;text-align:center}
.fd-steps-chev.down{transform:rotate(90deg)}
.fd-steps-count{font-weight:600;color:var(--text,#eee)}
.fd-steps-elem{font-weight:400;color:var(--text-dim,#999);margin-left:6px}
.fd-steps-body{border-top:1px solid var(--border,rgba(255,255,255,.08));padding:6px 8px 8px;max-height:340px;overflow-y:auto}
.fd-step-thought{margin:3px 0}
.fd-step-thought-head{display:flex;align-items:center;gap:6px;width:100%;border:none;background:transparent;color:var(--text-dim,#b9b9b9);font-size:12px;text-align:left;cursor:pointer;padding:4px 5px;border-radius:7px}
.fd-step-thought-head:hover{background:rgba(128,128,128,.1);color:var(--text,#fff)}
.fd-step-thought-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-step-thought-title .fd-t-ico{opacity:.85}
.fd-step-thought-body{margin:2px 0 4px 27px;padding:7px 10px;font-size:12px;color:var(--text-secondary,#cfcfcf);background:rgba(128,128,128,.1);border-left:2px solid rgba(35,131,226,.45);border-radius:0 8px 8px 0;white-space:pre-wrap;word-break:break-word;line-height:1.55}
.fd-step-action{display:flex;align-items:flex-start;gap:7px;padding:4px 7px;font-size:12px;color:var(--text-secondary,#c9c9c9)}
.fd-step-action .fd-step-ico{flex-shrink:0;width:14px;text-align:center;font-size:11px;margin-top:1px}
.fd-step-action.success{color:var(--text,#d9d9d9)}
.fd-step-action.success .fd-step-ico{color:#58c47a}
.fd-step-action.error{color:#e78c8c}
.fd-step-action.error .fd-step-ico{color:#ef6a6a}
.fd-step-action-detail{font-size:11px;color:#e0a3a3;display:block;margin-top:1px}
/* Composer */ /* Composer */
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative} .fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
.fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s} .fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s}
@@ -199,7 +231,7 @@
<div class="fd-ap-head"> <div class="fd-ap-head">
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span> <span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
<div class="fd-ap-head-main"> <div class="fd-ap-head-main">
<div class="fd-ap-title">How can I help you today?</div> <div class="fd-ap-title" x-text="currentConv && currentConv.title ? currentConv.title : 'How can I help you today?'"></div>
<div class="fd-ap-sub" x-text="providerModelLabel"></div> <div class="fd-ap-sub" x-text="providerModelLabel"></div>
</div> </div>
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button> <button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
@@ -223,7 +255,11 @@
<template x-if="tab==='history'"> <template x-if="tab==='history'">
<div> <div>
<template x-for="c in conversations" :key="c.id"> <template x-for="c in conversations" :key="c.id">
<div class="fd-ap-hist-item" :class="{active: currentConv && currentConv.id===c.id}" @click="loadConversation(c.id)"> <div class="fd-ap-hist-item" :class="{active: currentConv && currentConv.id===c.id}"
@click="historyClick(c.id)"
@mouseenter="previewConversation(c, $event)" @mouseleave="schedulePreviewHide()"
@touchstart="startLongPress(c, $event)" @touchend="onTouchEnd()"
@touchmove="onTouchCancel()" @touchcancel="onTouchCancel()">
<div x-text="c.title || 'New conversation'"></div> <div x-text="c.title || 'New conversation'"></div>
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div> <div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
</div> </div>
@@ -253,6 +289,38 @@
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div> <div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
<div class="fd-agent-msg-body"> <div class="fd-agent-msg-body">
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div> <div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
<!-- Steps accordion (reasoning thoughts + tool actions) -->
<div class="fd-steps" x-show="m.steps && m.steps.length" x-cloak>
<button class="fd-steps-head" type="button" @click="toggleSteps(m)">
<span class="fd-steps-chev" :class="{down: m.stepsOpen}">▶</span>
<span class="fd-steps-count" x-text="m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes'"></span>
<span class="fd-steps-elem" x-text="m.generating ? 'en cours…' : (m.stepsOpen ? 'réduire' : 'voir le déroulé')"></span>
</button>
<div class="fd-steps-body" x-show="m.stepsOpen" x-cloak>
<template x-for="(s, si) in m.steps" :key="si">
<div>
<template x-if="s.type==='thought'">
<div class="fd-step-thought">
<button class="fd-step-thought-head" type="button" @click="toggleStep(s)">
<span class="fd-steps-chev" :class="{down: s.open}">▶</span>
<span class="fd-step-thought-title"><span class="fd-t-ico">💭</span> <span x-text="s.title || 'Réflexion'"></span></span>
</button>
<div class="fd-step-thought-body" x-show="s.open" x-text="s.content"></div>
</div>
</template>
<template x-if="s.type==='action'">
<div class="fd-step-action" :class="s.status || 'success'" :title="(s.detail && s.status==='error') ? s.detail : s.tool || ''">
<span class="fd-step-ico" x-text="(s.status==='success') ? '✓' : '✗'"></span>
<div>
<div x-text="s.label"></div>
<div class="fd-step-action-detail" x-show="s.status==='error' && s.detail" x-text="s.detail" x-cloak></div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div> <div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div> <div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div> <div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
@@ -276,7 +344,7 @@
</div> </div>
</div> </div>
</template> </template>
<div class="fd-agent-thinking" x-show="streaming">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div> <div class="fd-agent-thinking" x-show="streaming && !(_livePh && _livePh.steps && _livePh.steps.length)">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
<div class="fd-ap-notice" x-show="notice" x-cloak x-text="notice"></div> <div class="fd-ap-notice" x-show="notice" x-cloak x-text="notice"></div>
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div> <div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
</div> </div>
@@ -372,6 +440,18 @@
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a> <a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
</div> </div>
</div> </div>
{# Historique : aperçu (hover / long-press) #}
<div class="fd-ap-preview" x-ref="convPreview" :class="{show: previewVisible}"
@mouseenter="cancelPreviewHide()" @mouseleave="schedulePreviewHide()" x-cloak>
<div class="fd-ap-preview-title" x-text="previewTitle"></div>
<div class="fd-ap-preview-meta" x-text="previewMeta"></div>
<div class="fd-ap-preview-empty" x-show="!previewStart && !previewEnd" x-text="previewEmptyHint"></div>
<div class="fd-ap-preview-label" x-show="previewStart" x-cloak>Début</div>
<div class="fd-ap-preview-txt" x-show="previewStart" x-text="previewStart"></div>
<div class="fd-ap-preview-label" x-show="previewEnd" x-cloak>Réponse</div>
<div class="fd-ap-preview-txt" x-show="previewEnd" x-text="previewEnd"></div>
</div>
</div> </div>
<script> <script>
@@ -423,6 +503,9 @@
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null, mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
cmdFocus: -1, slashDismiss: false, cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null, toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
previewVisible: false, previewTitle: '', previewMeta: '', previewStart: '', previewEnd: '',
_previewCache: null, _previewTimer: null, _longPressTimer: null, _suppressNextClick: false,
_slashSeq: 0, _slashSeq: 0,
init(){ init(){
@@ -498,6 +581,7 @@
if(!this.open) return; if(!this.open) return;
this.open = false; this.open = false;
this.modelOpen = false; this.modelOpen = false;
this.hidePreview();
if(this._applyState) this._applyState(); if(this._applyState) this._applyState();
}, },
toggle(){ this.open ? this.close() : this.openAgent(); }, toggle(){ this.open ? this.close() : this.openAgent(); },
@@ -740,6 +824,7 @@
var self = this; var self = this;
this.tab='chat'; this.tab='chat';
this.errorMsg = ''; this.errorMsg = '';
this.hidePreview();
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){ fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
self.currentConv = d.conversation; self.currentConv = d.conversation;
self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; }); self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; });
@@ -747,8 +832,119 @@
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; } if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; }
self.applyModels(); self.applyModels();
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; } if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
// Rebuild the steps accordion from the audit trail (actions only —
// the reasoning thoughts are not persisted, only shown live).
self.attachPersistedSteps(id);
}).catch(function(){}); }).catch(function(){});
}, },
attachPersistedSteps(id){
var self = this;
fetch('/api/agent/conversations/'+id+'/actions').then(function(r){return r.json()}).then(function(d){
var acts = d.actions || [];
if(!acts.length) return;
var lastAsst = null;
for(var i=self.messages.length-1;i>=0;i--){
var m = self.messages[i];
if(m.role==='assistant' && !m.proposal){ lastAsst = m; break; }
}
if(!lastAsst) return;
var steps = acts.map(function(a){
var res = null;
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch(e){ res = {}; }
var msg = (res && res.message) ? res.message : '';
return agentPanel._actionStep({
tool: a.tool_name, status: a.status, target_id: a.target_id,
message: msg, detail: msg || ''
});
});
lastAsst.steps = steps;
lastAsst.stepsOpen = false;
}).catch(function(){});
},
get previewEmptyHint(){ return 'Aucun échange enregistré pour cette conversation.'; },
historyClick(id){
if(this._suppressNextClick){ this._suppressNextClick = false; return; }
this.loadConversation(id);
},
_previewSummary(msgs){
var first = '', last = '';
for(var i=0;i<msgs.length;i++){
var r = msgs[i].role;
var t = (msgs[i].content || '');
if(r==='user'){ if(!first && t.trim()) first = agentPanel._strip(t); }
else if(r==='assistant'){ if(t.trim()) last = agentPanel._strip(t); }
}
return { first: first, last: last, total: msgs.length };
},
previewConversation(c, ev){
var self = this;
// On touch: a quick tap triggers a synthetic mouseenter AFTER touchend —
// skip it so we don't flash the preview before navigating (the long-press
// timer handles the preview itself).
if(ev && ev.type === 'mouseenter' && self._suppressEnterUntil && Date.now() < self._suppressEnterUntil) return;
var rect = (ev && ev.currentTarget) ? ev.currentTarget.getBoundingClientRect() : null;
var cache = self._previewCache || (self._previewCache = {});
function show(sum){
if(!sum) return;
self.previewTitle = c.title || 'New conversation';
var dateStr = (c.updated_at || '').replace('T', ' ').slice(0, 16);
self.previewMeta = sum.total + ' message(s)' + (dateStr ? ' · ' + dateStr : '');
self.previewStart = sum.first || '';
self.previewEnd = sum.last || '';
self.previewVisible = true;
self.cancelLongPress();
self.$nextTick(function(){
var el = self.$refs && self.$refs.convPreview;
if(!el) return;
var pw = el.offsetWidth || 290;
var m = 10;
var x = rect ? (rect.left - pw - m) : m;
if(x < m && rect) x = rect.right + m;
var y = rect ? Math.min(rect.top, window.innerHeight - el.offsetHeight - m) : m;
if(y < m) y = m;
el.style.left = Math.round(x) + 'px';
el.style.top = Math.round(y) + 'px';
});
}
if(cache[c.id]){ show(cache[c.id]); return; }
fetch('/api/agent/conversations/'+c.id).then(function(r){ return r.json(); }).then(function(d){
var sum = self._previewSummary(d.messages || []);
cache[c.id] = sum;
show(sum);
}).catch(function(){ self.schedulePreviewHide(); });
},
schedulePreviewHide(){
var self = this;
if(this._previewTimer) clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(function(){ self.previewVisible = false; self._previewTimer = null; }, 220);
},
cancelPreviewHide(){
if(this._previewTimer){ clearTimeout(this._previewTimer); this._previewTimer = null; }
},
startLongPress(c, ev){
var self = this;
this.cancelLongPress();
this._longPressTimer = setTimeout(function(){
self._suppressNextClick = true;
self.previewConversation(c, ev);
}, 550);
},
cancelLongPress(){
if(this._longPressTimer){ clearTimeout(this._longPressTimer); this._longPressTimer = null; }
},
onTouchEnd(){
// A quick tap: suppress the synthetic mouseenter preview flash right
// before the click navigates into the conversation.
this.cancelLongPress();
this._suppressEnterUntil = Date.now() + 400;
},
onTouchCancel(){
this.cancelLongPress();
},
hidePreview(){
if(this._previewTimer){ clearTimeout(this._previewTimer); this._previewTimer = null; }
this.previewVisible = false;
},
newConversation(){ newConversation(){
var self = this; var self = this;
var body = {title:'Nouvelle conversation'}; var body = {title:'Nouvelle conversation'};
@@ -1592,6 +1788,16 @@
}).catch(function(){ self.toast('Retour non envoyé.', true); }); }).catch(function(){ self.toast('Retour non envoyé.', true); });
}, },
toggleSteps(m){
if(!m) return;
m.stepsOpen = !m.stepsOpen;
this.$nextTick(function(){ this.scrollBottom(); });
},
toggleStep(s){
if(!s) return;
s.open = !s.open;
},
// ── Send / SSE ── // ── Send / SSE ──
async send(){ async send(){
var self = this; var self = this;
@@ -1642,6 +1848,23 @@
self.streaming = true; self.streaming = true;
self.liveReasoning = '…'; self.liveReasoning = '…';
self._livePh = null;
var runSteps = [];
var ph = null;
function ensurePlaceholder(finalContent){
if(ph) return ph;
ph = {id:'r-'+Date.now(), role:'assistant', generating: !finalContent,
content: finalContent || '', steps: [], stepsOpen: true,
model: self.autoLabel || ''};
self.messages.push(ph);
self._livePh = ph;
return ph;
}
function addStep(s){
runSteps.push(s);
if(ph){ ph.steps = runSteps.slice(); ph.generating = true; }
if(self._livePh){ self._livePh.steps = runSteps.slice(); }
}
self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText}); self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText});
self.scrollBottom(); self.scrollBottom();
@@ -1673,19 +1896,31 @@
if(!line) return; if(!line) return;
try{ try{
var ev = JSON.parse(line); var ev = JSON.parse(line);
if(ev.type==='reasoning'){ self.liveReasoning = ev.content; } if(ev.type==='reasoning'){
// A thought: collapsed by default, expandable to its full text.
addStep({type:'thought', title:agentPanel._thoughtTitle(ev.content),
content:(ev.content||'').replace(/\s+$/,''), open:false});
}
else if(ev.type==='action'){ else if(ev.type==='action'){
var msg = ev.status==='success' addStep(agentPanel._actionStep(ev));
? (ev.tool+' ✓ '+(ev.message||''))
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
} }
else if(ev.type==='final'){ else if(ev.type==='final'){
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content}); var fph = ensurePlaceholder(ev.content);
fph.generating = false;
fph.content = ev.content;
fph.steps = runSteps.slice();
fph.stepsOpen = false;
fph.model = self.autoLabel || '';
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} } if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
} }
else if(ev.type==='notice'){ self.notice = ev.message; } else if(ev.type==='notice'){ self.notice = ev.message; }
else if(ev.type==='error'){ self.errorMsg = ev.message; } else if(ev.type==='error'){
var eph = ensurePlaceholder();
eph.generating = false;
eph.content = '⚠️ ' + ev.message;
eph.steps = runSteps.slice();
self.errorMsg = ev.message;
}
}catch(e){} }catch(e){}
}); });
self.scrollBottom(); self.scrollBottom();
@@ -1695,13 +1930,22 @@
return read(); return read();
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; }) }).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
.finally(function(){ .finally(function(){
self.streaming = false; self.liveReasoning=''; self.streaming = false; self.liveReasoning=''; self._livePh = null;
self.fetchConversationsRefresh(); self.fetchConversationsRefresh();
}); });
}, },
fetchConversationsRefresh(){ fetchConversationsRefresh(){
var self = this; var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){ self.conversations = d.conversations || []; }).catch(function(){}); fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
self.conversations = d.conversations || [];
// keep the open conversation in sync so its auto-updated title shows
// immediately in the header and in a later History screen.
var cur = self.currentConv;
if(cur && cur.id){
var match = self.conversations.find(function(c){ return c.id === cur.id; });
if(match){ cur.title = match.title; cur.updated_at = match.updated_at; }
}
}).catch(function(){});
}, },
scrollBottom(){ scrollBottom(){
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; }); this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
@@ -1713,6 +1957,63 @@
Alpine.data('agentPanel', agentPanel); Alpine.data('agentPanel', agentPanel);
}); });
window.agentPanel = agentPanel; window.agentPanel = agentPanel;
// Plain-text helper for the history preview (strip markdown noise).
agentPanel._strip = function(t){
if(!t) return '';
return String(t)
.replace(/```[\s\S]*?```/g, ' [code] ')
.replace(/[#*_>`~]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 280);
};
// Short title for a reasoning thought (first non-empty line, truncated).
agentPanel._thoughtTitle = function(text){
var s = (text || '').trim();
if(!s) return 'Réflexion';
// Skip leading blank lines and get first meaningful line
var lines = s.split(/\r?\n/);
var first = '';
for(var i=0;i<lines.length;i++){
var ln = lines[i].trim();
if(ln){ first = ln; break; }
}
if(!first) return 'Réflexion';
first = first.replace(/^#+\s*/, ''); // strip markdown heading prefix
return first.length <= 72 ? first : first.slice(0,72).trimEnd() + '…';
};
// Past-tense label for each known agent action.
agentPanel._actionLabel = {
read_workspaces:'Espaces de travail listés', search_workspace:'Recherche effectuée',
read_document:'Document lu', read_collection:'Collection lue', read_page:'Page lue',
create_document:'Document créé', create_collection:'Collection créée',
create_page:'Page créée', create_view:'Vue créée', add_property:'Propriété ajoutée',
add_relation:'Relation ajoutée', update_page:'Page mise à jour',
write_blocks:'Contenu écrit', delete_document:'Document supprimé',
delete_page:'Page supprimée', delete_collection:'Collection supprimée',
apply_template:'Template appliqué', create_sub_item:'Sous-élément créé',
add_dependency:'Dépendance ajoutée', read_gitea_issues:'Issues Gitea lues',
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées'
};
// Build an action step object from an SSE event or a persisted action row.
agentPanel._actionStep = function(ev){
var ok = ev.status !== 'error';
var tool = String(ev.tool || '');
var label = agentPanel._actionLabel[tool] || tool.replace(/_/g,' ');
var extra = '';
if(ok && ev.target_id != null && ev.target_id !== '' && ev.target_id !== 0){
extra = ' → #' + ev.target_id;
}
if(ok && ev.message && ev.message.length <= 46){
extra += (extra ? ' — ' : ' · ') + ev.message;
}
return {
type:'action', tool:tool,
status: ok ? 'success' : 'error',
label: label + extra,
detail: ok ? '' : (ev.detail || ev.message || '')
};
};
// ═══════════ Context reader — document (page editor) ═══════════ // ═══════════ Context reader — document (page editor) ═══════════
window.fdReadPageContext = function(){ window.fdReadPageContext = function(){
+3 -3
View File
@@ -100,7 +100,7 @@
body.embed-mode .page-cover-area { padding-top: 0 !important; } body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style> </style>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script> <script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer></script> <script src="/static/js/sortable.min.js" defer></script>
</head> </head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}> <body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
@@ -710,7 +710,7 @@
</div> </div>
<script> <script data-cfasync="false">
// Inject CSRF token into HTMX headers // Inject CSRF token into HTMX headers
(function() { (function() {
const getCsrf = () => { const getCsrf = () => {
@@ -1912,7 +1912,7 @@
</div> </div>
</div> </div>
</div> </div>
<script> <script data-cfasync="false">
(function(){ (function(){
var PALETTE_ACTIONS = [ var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } }, { id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
+186 -64
View File
@@ -88,6 +88,13 @@
.row-title:hover{text-decoration:underline;} .row-title:hover{text-decoration:underline;}
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;} .row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
/* ── Tag chips (représentation + filtre) ── */
.row-tags{display:flex;align-items:center;gap:3px;flex-shrink:0;overflow:hidden;max-width:45%;}
.tag-chip{display:inline-flex;align-items:center;gap:3px;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;color:#fff;white-space:nowrap;border:none;cursor:pointer;font-family:inherit;}
.tag-chip.active{outline:2px solid var(--text-primary);outline-offset:1px;}
.lib-tag-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 16px 2px;}
.lib-tag-filter .tf-label{font-size:11px;color:var(--text-dim);margin-right:2px;}
/* ── Shared direction sub-filter pils ── */ /* ── Shared direction sub-filter pils ── */
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;} .lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;} .lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
@@ -271,6 +278,20 @@
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button> <button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
</div> </div>
<!-- Tag filter bar -->
<div class="lib-tag-filter" x-show="workspaceTags && workspaceTags.length > 0" x-cloak>
<span class="tf-label">Tags:</span>
<template x-for="t in workspaceTags" :key="'ltf'+t.id">
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
:style="{ background: t.color }"
@click="toggleTagFilter(t.name)">
<span x-text="t.name"></span>
<span style="font-size:10px;opacity:.7" x-text="'('+(t.count||0)+')'"></span>
</button>
</template>
<button x-show="tagFilter" class="tag-chip" style="background:var(--bg-tertiary);color:var(--text-secondary);" @click="tagFilter='', _recomputeFlatItems()">clear</button>
</div>
<!-- Search bar (toggleable) --> <!-- Search bar (toggleable) -->
<div class="lib-search-bar" x-show="searchOpen" x-transition> <div class="lib-search-bar" x-show="searchOpen" x-transition>
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()"> <input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
@@ -279,6 +300,10 @@
<!-- ── Multi-select bar ── --> <!-- ── Multi-select bar ── -->
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition> <div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
<span class="sel-count" x-text="selectedCount + ' selected'"></span> <span class="sel-count" x-text="selectedCount + ' selected'"></span>
<button @click="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="clearSelection()" title="Clear selection"> <button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
@@ -329,6 +354,7 @@
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}"> <div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table-header"> <div class="lib-table-header">
<div class="th lib-col-name"> <div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page name Page name
</div> </div>
@@ -358,47 +384,7 @@
</div> </div>
</div> </div>
<!-- ── Row context menu ── --> {% include "_ctx_menu.html" %}
<div class="more-menu" x-show="ctxMenuItem" @click.outside="ctxMenuItem=null" x-transition
:style="'top:' + ctxMenuY + 'px;left:' + ctxMenuX + 'px;'">
<template x-if="ctxMenuItem">
<div>
<div class="menu-item" @click="openItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open
</div>
<div class="menu-item" @click="openPeek(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
Open in side peek
</div>
<div class="menu-item" @click="startRename(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
Rename
</div>
<div class="menu-item" @click="toggleFavoriteItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<span x-text="ctxMenuItem.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div>
<div class="menu-item" @click="duplicateItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Duplicate
</div>
<div class="menu-item" @click="copyLink(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy link
</div>
<div class="menu-item" @click="openMovePicker([ctxMenuItem.id]); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="deleteItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash
</div>
</div>
</template>
</div>
<!-- ── Move-to modal ── --> <!-- ── Move-to modal ── -->
<div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition> <div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition>
@@ -411,7 +397,7 @@
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div> <div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id"> <template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)"> <div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="it.icon ? getSvgIcon(it.icon,14) : getSvgIcon('file',14)"></span> <span x-text="it.title"></span> <span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
</div> </div>
</template> </template>
</div> </div>
@@ -428,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
<div class="peek-title"> <div class="peek-title">
<span x-html="peekItem.icon ? getSvgIcon(peekItem.icon,14) : getSvgIcon('file',14)"></span> <span x-html="_renderIcon(peekItem)"></span>
<span x-text="peekItem.title"></span> <span x-text="peekItem.title"></span>
</div> </div>
<button @click="openItem(peekItem)" title="Open full page"> <button @click="openItem(peekItem)" title="Open full page">
@@ -470,7 +456,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script data-cfasync="false">
function libraryPage() { function libraryPage() {
return { return {
tab: 'recents', tab: 'recents',
@@ -502,17 +488,16 @@ function libraryPage() {
showAuthor: false, showAuthor: false,
showSource: false, showSource: false,
// Tags
workspaceTags: [],
tagFilter: '',
// Sort // Sort
sortBy: '', sortBy: '',
// More menu // More menu
moreOpen: false, moreOpen: false,
// Row context menu
ctxMenuItem: null,
ctxMenuX: 0,
ctxMenuY: 0,
// Move-to modal // Move-to modal
moveModalOpen: false, moveModalOpen: false,
moveIds: [], moveIds: [],
@@ -593,19 +578,24 @@ function libraryPage() {
} else { } else {
html += '<span style="width:18px;flex-shrink:0;"></span>'; html += '<span style="width:18px;flex-shrink:0;"></span>';
} }
// Icon — folders get a folder icon // Icon — monochrome, comme local-workspace (emoji custom si défini)
var isFolder = item.is_folder || item.has_children; var isFolder = item.is_folder || item.has_children;
if (isFolder) { html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
} else {
html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</span>';
}
// Title or rename input // Title or rename input
if (isRenaming) { if (isRenaming) {
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">'; html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
} else { } else {
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>'; html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
} }
// Tags
if (item.tags && item.tags.length) {
html += '<span class="row-tags">';
for (var ti = 0; ti < item.tags.length; ti++) {
var tg = item.tags[ti];
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
}
html += '</span>';
}
// OPEN button (side peek) — hidden for folders // OPEN button (side peek) — hidden for folders
if (!isFolder) { if (!isFolder) {
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">'; html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
@@ -627,12 +617,15 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; }, _escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }, _escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(icon, fallback) { _renderIcon(item) {
var name = icon || fallback || 'file'; if (!item) return getSvgIcon('file', 14);
// Only allow known icon names, otherwise use fallback // Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
var valid = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag','bar-chart','grid','list','align-left','zap','paperclip','key','refresh','upload']; if (item.page_icon) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(item.page_icon) + '</span>';
if (valid.indexOf(name) === -1) name = fallback || 'file'; if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
return getSvgIcon(name, 14); if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
var n = (item.title || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
return getSvgIcon('file', 14);
}, },
_recomputeFlatItems() { _recomputeFlatItems() {
@@ -644,6 +637,12 @@ function libraryPage() {
return (r.title || '').toLowerCase().indexOf(q) !== -1; return (r.title || '').toLowerCase().indexOf(q) !== -1;
}); });
} }
if (this.tagFilter) {
var tf = this.tagFilter;
result = result.filter(function(r) {
return (r.tags || []).some(function(t) { return t.name === tf; });
});
}
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails) // Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
this.flatItems.splice(0, this.flatItems.length, ...result); this.flatItems.splice(0, this.flatItems.length, ...result);
this.selectedCount = Object.keys(this.selected).length; this.selectedCount = Object.keys(this.selected).length;
@@ -706,9 +705,30 @@ function libraryPage() {
evt.preventDefault(); evt.preventDefault();
var item = this._findItem(id); var item = this._findItem(id);
if (!item) return; if (!item) return;
this.ctxMenuItem = item; if (!(window.Alpine && Alpine.store('fdCtx'))) return;
this.ctxMenuX = Math.min(evt.clientX, window.innerWidth - 220); /* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library
this.ctxMenuY = Math.min(evt.clientY, window.innerHeight - 320); complète l'union. Open folder/tags non prévus côté library :
fdCtx les cache automatiquement via handlers absents. */
var self = this;
var handlers = {
open: function (n) { self.openItem(n); },
openTab: function (n) { self.openInNewTab(n); },
peek: function (n) { self.openPeek(n); },
rename: function (n) { self.startRename(n); },
setIcon: function (icon) { self.setItemIcon(item, icon); },
duplicate: function (n) { self.duplicateItem(n); },
link: function (n) { self.copyLink(n); },
move: function (n) { self.openMovePicker([n.id]); },
fav: function (n) { self.toggleFavoriteItem(n); },
delete: function (n) { self.deleteItem(n); },
tagExisting: function (t) { self.ctxAddExistingTag(t); },
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
};
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
var store = Alpine.store('fdCtx');
store.setAvail(self._ctxAvailTags(item));
store.openMenu(evt, item, 'library', handlers);
}, },
// ── Drag & drop (move / reparent) ── // ── Drag & drop (move / reparent) ──
@@ -843,6 +863,7 @@ function libraryPage() {
this.tab = t; this.tab = t;
} }
this._recomputeEmpty(); this._recomputeEmpty();
this.loadWorkspaceTags();
console.log('[Library] loading tab: ' + this.tab); console.log('[Library] loading tab: ' + this.tab);
this.loadTab(); this.loadTab();
}, },
@@ -852,6 +873,7 @@ function libraryPage() {
if (t === 'repository' && !this.isGiteaActive) return; if (t === 'repository' && !this.isGiteaActive) return;
this.tab = t; this.tab = t;
this.shareDir = 'all'; this.shareDir = 'all';
this.tagFilter = '';
this.items = []; this.items = [];
this.selected = {}; this.selected = {};
this.selectedCount = 0; this.selectedCount = 0;
@@ -1073,6 +1095,106 @@ function libraryPage() {
if (window.showToast) window.showToast('Link copied', 'success'); if (window.showToast) window.showToast('Link copied', 'success');
}, },
openInNewTab(item) {
if (!item || !item.id) return;
window.open('/pages/' + item.id, '_blank');
},
removeItemFromRecents(item) {
if (!item) return;
var id = item.id;
this.items = this.items.filter(function(it) { return it.id !== id; });
this._recomputeFlatItems();
if (window.showToast) window.showToast('Removed from Recents', 'success');
},
async setItemIcon(item, icon) {
if (!item || !item.id) return;
icon = (icon || '').trim();
if (!icon) return;
try {
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this._getCsrf()},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
item.page_icon = icon;
this._renderTable();
if (window.showToast) window.showToast('Icon updated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to update icon', 'error');
}
},
// ── Tags ──
async loadWorkspaceTags() {
try {
var r = await fetch('/api/settings/tags/all');
var d = await r.json();
this.workspaceTags = d.tags || [];
} catch(e) { this.workspaceTags = []; }
},
toggleTagFilter(tagName) {
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
this._recomputeFlatItems();
},
_ctxAvailTags(item) {
return (this.workspaceTags || []).filter(function(t) {
return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; });
});
},
_ctxRefreshAvail() {
var store = window.Alpine && Alpine.store('fdCtx');
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
},
ctxAddExistingTag(tag) {
this.ctxAddNewTag(tag.name, tag.color);
},
ctxRemoveTag(tagId) {
var store = window.Alpine && Alpine.store('fdCtx');
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
self.loadWorkspaceTags();
self._ctxRefreshAvail();
self._recomputeFlatItems();
});
},
async ctxAddNewTag(tagName, color) {
tagName = (tagName || '').trim();
if (!tagName) return;
var store = window.Alpine && Alpine.store('fdCtx');
var item = store && store.node;
if (!item) return;
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
var d = await r.json();
var cur = item.tags || [];
if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]);
await this.loadWorkspaceTags();
this._ctxRefreshAvail();
this._recomputeFlatItems();
}
} catch(e) {}
},
async toggleFavoriteItem(item) { async toggleFavoriteItem(item) {
if (!item) return; if (!item) return;
var csrf = this._getCsrf(); var csrf = this._getCsrf();
+316 -235
View File
@@ -59,6 +59,19 @@
.ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);} .ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
.ws-select-bar button svg{width:14px;height:14px;} .ws-select-bar button svg{width:14px;height:14px;}
/* ── Multi-select "…" dropdown (Library-style) ── */
.ws-select-menu{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:300;padding:6px;}
.ws-select-menu .menu-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.ws-select-menu .menu-item:hover{background:var(--bg-hover);}
.ws-select-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.ws-select-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.ws-select-menu .menu-danger{color:var(--danger);}
.ws-select-menu .more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Move-to modal list items ── */
.move-modal-item{padding:7px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:6px;display:flex;align-items:center;gap:8px;}
.move-modal-item:hover{background:var(--bg-hover);}
/* ── Table (Notion-style rows) ── */ /* ── Table (Notion-style rows) ── */
.ws-table{width:100%;} .ws-table{width:100%;}
.ws-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0 4px;} .ws-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0 4px;}
@@ -466,7 +479,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;} .preview-leave-end{transform:translateX(100%);opacity:0;}
</style> </style>
<script> <script data-cfasync="false">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData'); console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() { window._wsInitData = (function() {
return { return {
@@ -513,18 +526,13 @@ window._wsInitData = (function() {
viewIcons: {tree:'',list:'',details:'',title:'',content:''}, viewIcons: {tree:'',list:'',details:'',title:'',content:''},
viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'}, viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
// Context menu // Context menu — state lives in the shared fdCtx Alpine store (_ctx_menu.html)
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
ctxTagAdding: false,
ctxTagExistingOpen: false,
ctxAvailTags: [],
ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
// Tags // Tags
workspaceTags: [], workspaceTags: [],
tagFilter: '', tagFilter: '',
tagInputId: null, tagInputId: null,
tagsVersion: 0,
// Preview panel // Preview panel
previewItem: null, previewItem: null,
@@ -534,6 +542,15 @@ window._wsInitData = (function() {
selectedIds: {}, selectedIds: {},
selectedIdsCount: 0, selectedIdsCount: 0,
lastSelectedId: null, lastSelectedId: null,
allSelected: false,
moreOpen: false,
// Move-to modal
moveModalOpen: false,
moveIds: [],
moveSearchQuery: '',
moveCandidates: [],
_moveAllCandidates: [],
// Undo // Undo
undoVisible: false, undoVisible: false,
@@ -685,6 +702,7 @@ window._wsInitData = (function() {
this.flatTree = ft; this.flatTree = ft;
// filteredTableItems // filteredTableItems
this._computeFilteredTableItems(); this._computeFilteredTableItems();
this._syncSelection();
}, },
// ── Pin/Favorite items ── // ── Pin/Favorite items ──
@@ -702,7 +720,8 @@ window._wsInitData = (function() {
// ── Multi-select ── // ── Multi-select ──
toggleSelect(node, ev) { toggleSelect(node, ev) {
if (ev.shiftKey && this.lastSelectedId) { var e = ev || {};
if (e.shiftKey && this.lastSelectedId) {
// Range select // Range select
var allIds = this._collectVisibleIds(this.displayTree); var allIds = this._collectVisibleIds(this.displayTree);
var start = allIds.indexOf(this.lastSelectedId); var start = allIds.indexOf(this.lastSelectedId);
@@ -711,18 +730,19 @@ window._wsInitData = (function() {
var from = Math.min(start, end), to = Math.max(start, end); var from = Math.min(start, end), to = Math.max(start, end);
for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true; for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true;
} }
} else if (ev.ctrlKey || ev.metaKey) { } else if (e.ctrlKey || e.metaKey) {
// Toggle single // Toggle single, keep others
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id]; if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
else this.selectedIds[node.id] = true; else this.selectedIds[node.id] = true;
} else { } else {
// Replace selection // Notion-style: toggle this item, keep the rest of the selection
for (var k in this.selectedIds) delete this.selectedIds[k]; if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
this.selectedIds[node.id] = true; else this.selectedIds[node.id] = true;
} }
this.lastSelectedId = node.id; this.lastSelectedId = node.id;
// Force reactivity // Force reactivity
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length; this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
}, },
_collectVisibleIds(nodes) { _collectVisibleIds(nodes) {
var ids = []; var ids = [];
@@ -755,16 +775,19 @@ window._wsInitData = (function() {
clearSelection() { clearSelection() {
for (var k in this.selectedIds) delete this.selectedIds[k]; for (var k in this.selectedIds) delete this.selectedIds[k];
this.selectedIds = {}; this.selectedIds = {};
this.selectedIdsCount = 0; this.moreOpen = false;
this._syncSelection();
}, },
selectAll(ev) { selectAll(ev) {
if (ev.target.checked) { // Toggle: select / unselect all currently visible items
if (this.allSelected) {
for (var k in this.selectedIds) delete this.selectedIds[k];
} else {
var all = this._collectVisibleIds(this.displayTree); var all = this._collectVisibleIds(this.displayTree);
for (var i=0; i<all.length; i++) this.selectedIds[all[i]] = true; for (var i=0; i<all.length; i++) this.selectedIds[all[i]] = true;
} else {
for (var k in this.selectedIds) delete this.selectedIds[k];
} }
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length; this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
}, },
// ── Table view data ── // ── Table view data ──
@@ -841,7 +864,7 @@ window._wsInitData = (function() {
selectForPreview(node) { selectForPreview(node) {
var panel = document.getElementById('ws-preview-panel'); var panel = document.getElementById('ws-preview-panel');
if (!panel) return; if (!panel) return;
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format); document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
document.getElementById('ws-preview-title').textContent = node.name; document.getElementById('ws-preview-title').textContent = node.name;
var openBtn = document.getElementById('ws-preview-open-btn'); var openBtn = document.getElementById('ws-preview-open-btn');
openBtn.onclick = function() { window.location = '/pages/' + node.id; }; openBtn.onclick = function() { window.location = '/pages/' + node.id; };
@@ -980,61 +1003,95 @@ window._wsInitData = (function() {
// ── Content snippet for content view ── // ── Content snippet for content view ──
contentSnippetMap: {}, contentSnippetMap: {},
// ── Context menu ── // ── Context menu (menu unifié partagé via le store Alpine fdCtx) ──
onContextMenu(ev) { onContextMenu(ev) {
var el = ev.target.closest('[data-ws-id]'); var el = ev.target.closest('[data-ws-id]');
var menu = document.querySelector('.more-menu'); if (!el) return;
if (!el || !menu) { if (menu) menu.style.display = 'none'; return; }
var id = parseInt(el.getAttribute('data-ws-id')); var id = parseInt(el.getAttribute('data-ws-id'));
if (!id) { menu.style.display = 'none'; return; } if (!id) return;
var node = this._findNodeById(this.displayTree, id); var node = this._findNodeById(this.displayTree, id) || this._findNodeById(this.tree, id);
if (!node) { menu.style.display = 'none'; return; } if (!node) return;
// Ensure workspace tags are loaded var store = window.Alpine && Alpine.store('fdCtx');
if (!this.workspaceTags || this.workspaceTags.length === 0) { if (!store) return;
this.loadWorkspaceTags(); var self = this;
} if (!this.workspaceTags || this.workspaceTags.length === 0) this.loadWorkspaceTags();
// Store in Alpine data for menu actions (ctxMenuOpenPage, etc.) store.setAvail(this._ctxAvailTags(node));
this.ctxMenu = {visible:true, x:ev.clientX, y:ev.clientY, node:node, tags:[...(node.tags||[])]}; var handlers = {
this.ctxTagAdding = false; open: function (n) { self.openPage(n.id); },
this.ctxTagExistingOpen = false; openTab: function (n) { window.open('/pages/' + n.id, '_blank'); },
this.ctxNewTagColor = '#787774'; peek: function (n) { self.openSidePeek(n.id); },
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) { folder: function (n) { if (n.is_folder) self.navigateToFolder(n.id); },
return !(node.tags||[]).some(function(nt) { return nt.id === t.id; }); rename: function (n) { self.startRename(n); },
setIcon: function (icon) { self.setItemIcon(node, icon); },
duplicate: function (n) { self.duplicateItem(n); },
link: function (n) { self.copyItemLink(n); },
move: function (n) { self._ctxMove(n); },
fav: function (n) { self.toggleFavorite(n); },
delete: function (n) { self.deleteWithUndo(n); },
tagExisting: function (t) { self.ctxAddExistingTag(t); },
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
};
store.openMenu(ev, node, 'workspace', handlers);
},
_ctxAvailTags(node) {
return (this.workspaceTags || []).filter(function(t) {
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
}); });
// DOM manipulation (bypasses Alpine reactivity issues) },
menu.style.display = 'block';
menu.style.left = ev.clientX + 'px'; _ctxRefreshAvail() {
menu.style.top = ev.clientY + 'px'; var store = window.Alpine && Alpine.store('fdCtx');
// Reposition if offscreen if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
var rect = menu.getBoundingClientRect(); },
var x = ev.clientX, y = ev.clientY;
if (x + rect.width > window.innerWidth - 4) x = window.innerWidth - rect.width - 4; copyItemLink(node) {
if (y + rect.height > window.innerHeight - 4) y = window.innerHeight - rect.height - 4; if (!node) return;
if (x < 4) x = 4; navigator.clipboard.writeText(window.location.origin + '/pages/' + node.id);
if (y < 4) y = 4; if (window.showToast) window.showToast('Link copied', 'success');
menu.style.left = x + 'px'; },
menu.style.top = y + 'px';
this.ctxMenu.x = x; async toggleFavorite(node) {
this.ctxMenu.y = y; if (!node) return;
// Sync to _ctxMenuData for Alpine reactivity try {
var c = window._ctxMenuData; var method = node.favorited ? 'DELETE' : 'POST';
c.ctxMenu = this.ctxMenu; var r = await fetch('/board/api/favorites/' + node.id, { method: method });
c.ctxTagAdding = this.ctxTagAdding; if (r.ok) {
c.ctxTagExistingOpen = this.ctxTagExistingOpen; node.favorited = !node.favorited;
c.ctxAvailTags = this.ctxAvailTags; if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
c.ctxNewTagColor = this.ctxNewTagColor; if (window.showToast) window.showToast(node.favorited ? 'Added to Favorites' : 'Removed from Favorites', 'success');
// Also force Alpine update on the menu element }
var menuEl = document.querySelector('.more-menu'); } catch(e) {}
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) { },
var d = menuEl._x_dataStack[0];
d.ctxMenu = this.ctxMenu; async setItemIcon(node, icon) {
d.ctxTagAdding = this.ctxTagAdding; if (!node || !node.id) return;
d.ctxTagExistingOpen = this.ctxTagExistingOpen; icon = (icon || '').trim();
d.ctxAvailTags = [...this.ctxAvailTags]; if (!icon) return;
d.ctxNewTagColor = this.ctxNewTagColor; try {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
node.page_icon = icon;
this.displayTree = [...this.tree];
this._reloadAfterAction();
if (window.showToast) window.showToast('Icon updated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to update icon', 'error');
} }
}, },
_ctxMove(node) {
if (!node) return;
this.selectedIds = {};
this.selectedIds[node.id] = true;
this.openMovePicker();
},
// ── Long-press (mobile context menu) ── // ── Long-press (mobile context menu) ──
_touchStartX: 0, _touchStartX: 0,
_touchStartY: 0, _touchStartY: 0,
@@ -1079,84 +1136,39 @@ window._wsInitData = (function() {
this.ctxAddNewTag(tag.name, tag.color); this.ctxAddNewTag(tag.name, tag.color);
}, },
ctxMenuOpenPage() {
if (this.ctxMenu.node) this.openPage(this.ctxMenu.node.id);
document.querySelector('.more-menu').style.display = 'none';
},
ctxMenuOpenFolder() {
if (this.ctxMenu.node) this.navigateToFolder(this.ctxMenu.node.id);
document.querySelector('.more-menu').style.display = 'none';
},
ctxMenuRename() {
if (this.ctxMenu.node) { this.startRename(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxMenuDuplicate() {
if (this.ctxMenu.node) { this.duplicateItem(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxMenuDelete() {
if (this.ctxMenu.node) { this.deleteWithUndo(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxRemoveTag(tagId) { ctxRemoveTag(tagId) {
var node = this.ctxMenu.node; var store = window.Alpine && Alpine.store('fdCtx');
var node = store && store.node;
if (!node) return; if (!node) return;
this.removeTag(node.id, tagId); this.removeTag(node.id, tagId);
this.ctxMenu.tags = this.ctxMenu.tags.filter(function(t) { return t.id !== tagId; }); node.tags = (node.tags || []).filter(function(t) { return t.id !== tagId; });
// Refresh available tags this._ctxRefreshAvail();
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
return !(this.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
}.bind(this));
// Sync to _ctxMenuData
window._ctxMenuData.ctxAvailTags = this.ctxAvailTags;
window._ctxMenuData.ctxMenu = this.ctxMenu;
// Also force Alpine update
var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = this.ctxMenu;
}
}, },
async ctxAddNewTag(tagName, color) { async ctxAddNewTag(tagName, color) {
tagName = (tagName || '').trim(); tagName = (tagName || '').trim();
if (!tagName) return; if (!tagName) return;
var node = this.ctxMenu.node; var store = window.Alpine && Alpine.store('fdCtx');
var node = store && store.node;
if (!node) return; if (!node) return;
color = color || window._ctxMenuData.ctxNewTagColor; color = color || (store && store.newTagColor) || '#787774';
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', { try {
method: 'POST', headers: {'Content-Type':'application/json'}, var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
body: JSON.stringify({name: tagName.trim(), color: color}) method: 'POST', headers: {'Content-Type':'application/json'},
}); body: JSON.stringify({name: tagName, color: color})
if (r.ok) { });
var d = await r.json(); if (r.ok) {
this.ctxMenu.tags.push(d.tag); var d = await r.json();
this._updateNodeTags(this.tree, node.id, d.tag, 'add'); var cur = node.tags || [];
this.displayTree = [...this.tree]; if (!cur.some(function(t) { return t.id === d.tag.id; })) node.tags = cur.concat([d.tag]);
this.loadWorkspaceTags(); this._updateNodeTags(this.tree, node.id, d.tag, 'add');
// Refresh available tags this.displayTree = [...this.tree];
var self = this; this.tagsVersion++;
setTimeout(function() { self.loadWorkspaceTags().then(function() { await this.loadWorkspaceTags();
self.ctxAvailTags = (self.workspaceTags || []).filter(function(t) { this._computeFilteredTableItems();
return !(self.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; }); this._ctxRefreshAvail();
}); }
// Sync to _ctxMenuData } catch(e) {}
window._ctxMenuData.ctxAvailTags = self.ctxAvailTags;
window._ctxMenuData.ctxMenu = self.ctxMenu;
// Also force Alpine update
var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = self.ctxMenu;
}
// Close menu after successful add
document.querySelector('.more-menu').style.display = 'none';
}); }, 200);
}
this.ctxTagAdding = false;
}, },
_markTree(nodes, depth) { _markTree(nodes, depth) {
@@ -1177,7 +1189,7 @@ window._wsInitData = (function() {
for (var i=0; i<children.length; i++) { for (var i=0; i<children.length; i++) {
var c = children[i]; var c = children[i];
var hasKids = c.children && c.children.length > 0; var hasKids = c.children && c.children.length > 0;
var icon = this._fileIcon(c.name, c.is_folder, c.content_format); var icon = this._fileIcon(c.name, c.is_folder, c.content_format, c.page_icon);
var escName = this.esc(c.name); var escName = this.esc(c.name);
var escId = c.id; var escId = c.id;
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"' html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"'
@@ -1352,6 +1364,8 @@ window._wsInitData = (function() {
} }
} }
} }
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
}, },
async navigateToFolder(id) { async navigateToFolder(id) {
@@ -1392,6 +1406,8 @@ window._wsInitData = (function() {
this._saveExpandedState(); this._saveExpandedState();
this.previewItem = null; this.previewItem = null;
this._recompute(); this._recompute();
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
} catch(e) { console.error('Load folder error:', e); } } catch(e) { console.error('Load folder error:', e); }
}, },
@@ -1466,6 +1482,8 @@ window._wsInitData = (function() {
// ── Keyboard shortcuts ── // ── Keyboard shortcuts ──
onKeydown(ev) { onKeydown(ev) {
var tag = ev.target && ev.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't hijack typing
if (this.renamingId) return; // Don't intercept during rename if (this.renamingId) return; // Don't intercept during rename
if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) { if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
@@ -1725,7 +1743,8 @@ window._wsInitData = (function() {
}, },
// ── File type icon ── // ── File type icon ──
_fileIcon(name, isFolder, contentFormat) { _fileIcon(name, isFolder, contentFormat, customIcon) {
if (customIcon) return '<span class="page-icon-svg" style="font-size:14px;line-height:1;">'+this.esc(customIcon)+'</span>';
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>'; if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
// FlowDeck internal pages (no extension, blocks/markdown format) // FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>'; if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
@@ -2225,7 +2244,9 @@ window._wsInitData = (function() {
// Update node tags in tree // Update node tags in tree
this._updateNodeTags(this.tree, itemId, d.tag, 'add'); this._updateNodeTags(this.tree, itemId, d.tag, 'add');
this.displayTree = [...this.tree]; this.displayTree = [...this.tree];
this.loadWorkspaceTags(); this.tagsVersion++;
await this.loadWorkspaceTags();
this._computeFilteredTableItems();
} }
} catch(e) {} } catch(e) {}
this.tagInputId = null; this.tagInputId = null;
@@ -2239,7 +2260,9 @@ window._wsInitData = (function() {
if (r.ok) { if (r.ok) {
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId); this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
this.displayTree = [...this.tree]; this.displayTree = [...this.tree];
this.loadWorkspaceTags(); this.tagsVersion++;
await this.loadWorkspaceTags();
this._computeFilteredTableItems();
} }
} catch(e) {} } catch(e) {}
}, },
@@ -2248,10 +2271,10 @@ window._wsInitData = (function() {
for (var i=0; i<nodes.length; i++) { for (var i=0; i<nodes.length; i++) {
if (nodes[i].id === itemId) { if (nodes[i].id === itemId) {
if (action === 'add') { if (action === 'add') {
if (!nodes[i].tags) nodes[i].tags = []; // Réassigne un nouveau tableau → déclenche la réactivité Alpine
// Avoid duplicates var cur = nodes[i].tags || [];
if (!nodes[i].tags.some(function(t) { return t.id === tag.id; })) { if (!cur.some(function(t) { return t.id === tag.id; })) {
nodes[i].tags.push(tag); nodes[i].tags = cur.concat([tag]);
} }
} else if (action === 'remove') { } else if (action === 'remove') {
nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; }); nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; });
@@ -2304,7 +2327,86 @@ window._wsInitData = (function() {
for (var k in this.selectedIds) delete this.selectedIds[k]; for (var k in this.selectedIds) delete this.selectedIds[k];
} }
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
// ── Selection sync (keeps checkboxes & counters in sync) ──
_syncSelection() {
this.selectedIdsCount = Object.keys(this.selectedIds).length; this.selectedIdsCount = Object.keys(this.selectedIds).length;
var total = this._collectVisibleIds(this.displayTree).length;
this.allSelected = total > 0 && this.selectedIdsCount >= total;
this._syncCheckboxes();
},
_syncCheckboxes() {
var boxes = document.querySelectorAll('input.item-checkbox[data-ws-id]');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = !!this.selectedIds[parseInt(boxes[i].getAttribute('data-ws-id'))];
}
},
// ── Multi-select actions (Library-style select bar) ──
_selectedIds() {
return Object.keys(this.selectedIds).map(Number);
},
copySelectedLinks() {
this.moreOpen = false;
var ids = Object.keys(this.selectedIds);
if (!ids.length) return;
var links = ids.map(function(id) { return window.location.origin + '/pages/' + id; });
navigator.clipboard.writeText(links.join('\n'));
if (window.showToast) window.showToast('Links copied', 'success');
},
async openMovePicker() {
var ids = this._selectedIds();
if (!ids.length) return;
this.moreOpen = false;
this.moveIds = ids;
this.moveSearchQuery = '';
this.moveModalOpen = true;
try {
// Fetch the full workspace tree so every folder is a valid destination
var r = await fetch('/api/local-workspace/tree');
var d = await r.json();
var folders = [];
this._collectFolders(d.tree || [], folders, 0);
this._moveAllCandidates = folders;
this.moveCandidates = folders.slice(0, 50);
} catch(e) {
this._moveAllCandidates = [];
this.moveCandidates = [];
}
},
_collectFolders(nodes, out, depth) {
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (n.is_folder) out.push({ id: n.id, name: n.name, depth: depth });
if (n.children) this._collectFolders(n.children, out, depth + 1);
}
},
doMoveSearch() {
var q = (this.moveSearchQuery || '').toLowerCase().trim();
if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 50); return; }
this.moveCandidates = this._moveAllCandidates.filter(function(it) {
return (it.name || '').toLowerCase().indexOf(q) !== -1;
}).slice(0, 50);
},
async confirmBulkMove(parentId) {
this.moveModalOpen = false;
var ids = this.moveIds;
this.moveIds = [];
if (!ids || !ids.length) return;
for (var i = 0; i < ids.length; i++) {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch(e) {}
}
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
} }
}; };
@@ -2315,32 +2417,12 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch(e) {} try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch(e) {}
} }
_wsInitData = window._wsInitData; _wsInitData = window._wsInitData;
// Objet léger pour le ctx-menu (évite le conflit $nextTick avec Alpine)
window._ctxMenuData = {
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
ctxTagAdding: false,
ctxTagExistingOpen: false,
ctxAvailTags: [],
ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
// Use Alpine proxy (window._wsData) for reactivity, not raw object (window._wsInitData)
_ws() { return window._wsData || window._wsInitData; },
ctxMenuOpenPage() { this._ws().ctxMenuOpenPage(); },
ctxMenuOpenFolder() { this._ws().ctxMenuOpenFolder(); },
ctxMenuRename() { this._ws().ctxMenuRename(); },
ctxMenuDuplicate() { this._ws().ctxMenuDuplicate(); },
ctxMenuDelete() { this._ws().ctxMenuDelete(); },
ctxAddExistingTag(t) { this._ws().ctxAddExistingTag(t); },
ctxAddNewTag(n, c) { this._ws().ctxAddNewTag(n, c); },
ctxRemoveTag(id) { this._ws().ctxRemoveTag(id); },
};
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length); console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script> </script>
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script> <script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split" <div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)" @contextmenu.prevent="_wsInitData.onContextMenu($event)"
@click.self="ctxMenu.visible = false"
@touchstart="onTouchStart($event)" @touchstart="onTouchStart($event)"
@touchend="onTouchEnd($event)" @touchend="onTouchEnd($event)"
@touchmove="onTouchMove($event)" @touchmove="onTouchMove($event)"
@@ -2470,14 +2552,38 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<input x-ref="searchInput" type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter" autocomplete="off"> <input x-ref="searchInput" type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter" autocomplete="off">
</div> </div>
<!-- Bulk actions bar --> <!-- Bulk actions bar (Library-style) -->
<div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition> <div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
<span class="sel-count" x-text="selectedIdsCount + ' selected'"></span> <span class="sel-count" x-text="selectedIdsCount + ' selected'"></span>
<button @click="clearSelection()" title="Clear"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> <button @click="clearSelection()" title="Clear selection">
<button @click="bulkDelete()" style="margin-left:auto;color:var(--danger);" title="Delete"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Delete
</button> </button>
<button @click="copySelectedLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
<button @click.stop="moreOpen=!moreOpen" title="More actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<div class="ws-select-menu" x-show="moreOpen" @click.outside="moreOpen=false" x-transition>
<div class="menu-item" @click="copySelectedLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="bulkDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash <span class="shortcut">Del</span>
</div>
</div>
</div>
</div> </div>
<!-- Filter chips (visual indicator when filter is active — shown in all views) --> <!-- Filter chips (visual indicator when filter is active — shown in all views) -->
@@ -2494,6 +2600,15 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Tree --> <!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;"> <div x-show="viewMode==='tree'" style="display:contents;">
<!-- Select all toggle -->
<div x-show="viewMode==='tree' && displayTree.length>0"
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin-bottom:2px;">
<input type="checkbox" class="item-checkbox" :checked="allSelected" @click="selectAll($event)" title="Select / unselect all">
<span style="font-size:12px;color:var(--text-secondary);cursor:pointer;user-select:none;" @click="selectAll($event)"
x-text="allSelected ? 'Clear selection' : 'Select all'"></span>
<span x-show="selectedIdsCount > 0" style="font-size:12px;color:var(--text-dim);" x-text="'(' + selectedIdsCount + ' selected)'"></span>
</div>
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)"> <ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
<!-- Parent folder ".." --> <!-- Parent folder ".." -->
<li x-show="currentFolder > 0" style="list-style:none;"> <li x-show="currentFolder > 0" style="list-style:none;">
@@ -2532,7 +2647,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<span style="width:16px;flex-shrink:0;"></span> <span style="width:16px;flex-shrink:0;"></span>
</template> </template>
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<template x-if="node.is_folder"> <template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)" <span class="name folder" @click.stop="navigateToFolder(node.id)"
@@ -2574,7 +2689,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</div> </div>
<!-- Children --> <!-- Children -->
<ul class="ws-tree" x-show="expanded[node.id]" x-transition <ul class="ws-tree" x-show="expanded[node.id]" x-transition
x-html="renderChildren(node.children, (node.depth||0)+1)"> x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
</ul> </ul>
</li> </li>
</template> </template>
@@ -2615,7 +2730,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Filter table (shown when search/filter/tag is active) --> <!-- Filter table (shown when search/filter/tag is active) -->
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0"> <table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
<thead><tr> <thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAllFiltered($event)"></th> <th style="width:30px"><input type="checkbox" :checked="allSelected" @click="selectAllFiltered($event)"></th>
<th>Name</th> <th>Name</th>
<th>Path</th> <th>Path</th>
<th>Size</th> <th>Size</th>
@@ -2630,7 +2745,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<tr :class="{ selected: !!selectedIds[node.id] }"> <tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td> <td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td> <td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span> <span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span> <span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td> </td>
@@ -2687,7 +2802,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</tr> </tr>
<template x-for="node in displayTree" :key="'l'+node.id"> <template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"> <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td> <span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td> <td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td> <td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
@@ -2725,7 +2840,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<template x-for="node in displayTree" :key="'d'+node.id"> <template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer" <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td> <span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td> <td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td> <td class="col-path" x-text="node._path || '—'"></td>
@@ -2756,7 +2871,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<template x-for="node in displayTree" :key="'g'+node.id"> <template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" <div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }"> :class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div> <div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
<div class="title-card-name"> <div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span> <span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span> <span x-text="node.name"></span>
@@ -2783,7 +2898,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" <div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header"> <div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span> <span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></span> <span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span> <span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
@@ -2820,6 +2935,25 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span> <span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
</div> </div>
<!-- Move-to modal -->
<div class="modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" @keydown.escape="moveModalOpen=false" style="display:none">
<div class="modal-box" style="width:440px;max-width:90vw;padding:0;overflow:hidden;display:flex;flex-direction:column;max-height:60vh;">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text-primary);">Move to…</div>
<div style="padding:8px 12px;border-bottom:1px solid var(--border);">
<input class="modal-input" type="text" placeholder="Search folders..." x-model="moveSearchQuery" @input="doMoveSearch()" style="padding:7px 10px;font-size:13px;">
</div>
<div style="flex:1;overflow-y:auto;padding:4px;">
<div class="move-modal-item" @click="confirmBulkMove(0)">{{ fd_icon("folder",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="'mv'+it.id">
<div class="move-modal-item" :style="{paddingLeft: (8 + (it.depth||0)*20) + 'px'}" @click="confirmBulkMove(it.id)">
{{ fd_icon("folder",14) }} <span x-text="it.name"></span>
</div>
</template>
<div x-show="moveCandidates.length === 0" style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;">No folders found</div>
</div>
</div>
</div>
<!-- Create Modal --> <!-- Create Modal -->
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none"> <div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
<div class="modal-box"> <div class="modal-box">
@@ -2864,63 +2998,10 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</div><!-- /ws-main --> </div><!-- /ws-main -->
<!-- Context menu (Library-style) --> <!-- Context menu (Library-style) — MENU UNIQUE partagé (workspace ⇄ library) -->
<div class="more-menu" x-data="_ctxMenuData" style="display:none" {% include "_ctx_menu.html" %}
@click.outside="document.querySelector('.more-menu').style.display='none'"
@keydown.escape="document.querySelector('.more-menu').style.display='none'"
x-transition>
<div class="menu-item" @click="ctxMenuOpenPage()">{{ fd_icon("file",14) }} Open</div>
<div class="menu-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">{{ fd_icon("folder",14) }} Open folder</div>
<div class="menu-item" @click="ctxMenuRename()">{{ fd_icon("edit",14) }} Rename</div>
<div class="menu-item" @click="ctxMenuDuplicate()">{{ fd_icon("copy",14) }} Duplicate</div>
<div class="more-sep"></div>
<!-- Current tags -->
<template x-for="t in (ctxMenu.tags||[])" :key="'ctx-tag'+t.id">
<div class="menu-item" style="justify-content:space-between;">
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
<button class="tag-chip rm-btn" @click.stop="ctxRemoveTag(t.id)" title="Remove">×</button>
</div>
</template>
<div x-show="(ctxMenu.tags||[]).length === 0" style="padding:4px 10px;font-size:11px;color:var(--text-dim);">No tags</div>
<div class="more-sep"></div>
<!-- Add existing tag -->
<div class="menu-item" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0">
<span style="flex:1;">Add existing tag</span>
<span x-text="ctxTagExistingOpen ? '▾' : '▸'" style="font-size:11px;"></span>
</div>
<div x-show="ctxTagExistingOpen && ctxAvailTags.length > 0" style="padding:0 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
<template x-for="t in ctxAvailTags" :key="'ctx-avail'+t.id">
<div class="menu-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false" style="justify-content:space-between;">
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
<span style="font-size:10px;color:var(--text-dim);" x-text="t.count||''"></span>
</div>
</template>
</div>
<!-- Create new tag -->
<div class="menu-item" @click.stop="ctxTagAdding=!ctxTagAdding">
<span style="flex:1;">{{ fd_icon("plus",14) }} New tag</span>
<span x-text="ctxTagAdding ? '▾' : '▸'" style="font-size:11px;"></span>
</div>
<div x-show="ctxTagAdding" style="padding:6px 10px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:6px;">
<template x-for="c in ctxTagColors" :key="c">
<div class="color-swatch-ctx" :class="{ selected: ctxNewTagColor === c }"
:style="{background: c}" @click.stop="ctxNewTagColor=c"></div>
</template>
</div>
<div style="display:flex;gap:4px;">
<input class="tag-input" x-ref="ctxTagInput"
@keydown.enter="ctxAddNewTag($event.target.value), $event.target.value=''"
@keydown.escape="ctxTagAdding=false"
@click.stop placeholder="Tag name..." style="flex:1;">
<button class="btn btn-primary" @click.stop="ctxAddNewTag($refs.ctxTagInput.value), $refs.ctxTagInput.value=''" style="padding:2px 10px;font-size:12px;">Add</button>
</div>
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="ctxMenuDelete()">{{ fd_icon("trash",14) }} Delete</div>
</div>
<!-- Preview tooltip --> <!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible" <div class="file-preview" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }" :style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false"> @mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
+10 -1
View File
@@ -137,7 +137,16 @@
</header> </header>
<main class="pub-container"> <main class="pub-container">
<h1 class="pub-title">{{ title }}</h1> {% if cover_url %}
<div class="pub-cover" style="position:relative;margin:-40px -24px 32px;height:240px;overflow:hidden;">
<img src="{{ cover_url }}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;">
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.5) 100%);"></div>
</div>
{% endif %}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<span class="pub-icon" style="font-size:2rem;">{{ page_icon or fd_icon('file',24) }}</span>
<h1 class="pub-title">{{ title }}</h1>
</div>
{% if updated_at %} {% if updated_at %}
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div> <div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
{% endif %} {% endif %}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title> <title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer></script> <script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<style> <style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;} :root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
*{margin:0;padding:0;box-sizing:border-box;} *{margin:0;padding:0;box-sizing:border-box;}
+63
View File
@@ -0,0 +1,63 @@
// FlowDeck — ESLint flat config (v5.2.0).
// ESLint v9+ requires the flat config format; the legacy `.eslintrc.json`
// is no longer read. Run with: `npx eslint static/js`.
//
// Self-contained on purpose: no dependency on the `globals` npm package so the
// config works with a globally-installed ESLint (no node_modules required).
const browserGlobals = {
// Browser / DOM
window: "readonly", document: "readonly", navigator: "readonly",
location: "readonly", history: "readonly", screen: "readonly",
localStorage: "readonly", sessionStorage: "readonly", console: "readonly",
alert: "readonly", confirm: "readonly", prompt: "readonly", fetch: "readonly",
setTimeout: "readonly", clearTimeout: "readonly", setInterval: "readonly",
clearInterval: "readonly", requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly", requestIdleCallback: "readonly",
cancelIdleCallback: "readonly", queueMicrotask: "readonly",
XMLHttpRequest: "readonly", FormData: "readonly", Blob: "readonly",
File: "readonly", FileReader: "readonly", URL: "readonly",
URLSearchParams: "readonly", Image: "readonly", Event: "readonly",
CustomEvent: "readonly", EventSource: "readonly", WebSocket: "readonly",
DOMParser: "readonly", Node: "readonly", NodeList: "readonly",
Element: "readonly", HTMLElement: "readonly", getComputedStyle: "readonly",
matchMedia: "readonly", IntersectionObserver: "readonly",
MutationObserver: "readonly", ResizeObserver: "readonly",
performance: "readonly", crypto: "readonly", btoa: "readonly",
atob: "readonly", structuredClone: "readonly",
// ECMAScript built-ins
JSON: "readonly", Math: "readonly", Date: "readonly", Promise: "readonly",
Object: "readonly", Array: "readonly", String: "readonly", Number: "readonly",
Boolean: "readonly", Symbol: "readonly", Map: "readonly", Set: "readonly",
WeakMap: "readonly", WeakSet: "readonly", Error: "readonly",
TypeError: "readonly", RangeError: "readonly", RegExp: "readonly",
Proxy: "readonly", Reflect: "readonly", Intl: "readonly",
parseInt: "readonly", parseFloat: "readonly", isNaN: "readonly",
isFinite: "readonly", encodeURIComponent: "readonly",
decodeURIComponent: "readonly", encodeURI: "readonly", decodeURI: "readonly",
globalThis: "readonly", Infinity: "readonly", NaN: "readonly",
// FlowDeck front-end libraries
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
};
export default [
{
ignores: ["static/js/*.min.js", "static/js/vendor/**"],
},
{
files: ["static/js/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: browserGlobals,
},
rules: {
"no-unused-vars": ["warn", { args: "none" }],
"no-undef": "warn",
"no-extra-semi": "warn",
"no-empty": "warn",
},
},
];
BIN
View File
Binary file not shown.
+19 -5
View File
@@ -1,10 +1,10 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
# Parallel execution via pytest-xdist (v5.2.0). Override with `-n 0` to run # Parallel execution (pytest-xdist) is OPT-IN so a bare `pytest` never fails
# sequentially: `pytest -n 0`. # when xdist is not installed. Run locally with `pytest -n auto`; CI passes
# Note: some tests use shared temp directories — run sequentially for stability. # `-n auto` explicitly after installing requirements-dev.txt.
addopts = "-n 0"
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
@@ -18,4 +18,18 @@ select = ["E", "F", "I", "UP", "B", "W"]
ignore = ["E501", "UP035"] ignore = ["E501", "UP035"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["app", "tests"] known-first-party = ["app", "tests"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI requires dependency markers (Depends, Query, ...) in argument
# defaults — that is not the bug B008 warns about.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
"fastapi.Path",
"fastapi.Body",
"fastapi.Form",
"fastapi.File",
"fastapi.Header",
"fastapi.Cookie",
]
+1 -1
View File
@@ -22,7 +22,7 @@
xhr.open('POST', '/api/frontend-error', true); xhr.open('POST', '/api/frontend-error', true);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(error)); xhr.send(JSON.stringify(error));
} catch(e) { /* fail silently */ } } catch { /* fail silently */ }
} }
window.addEventListener('error', function(e) { window.addEventListener('error', function(e) {
+22 -7
View File
@@ -1,9 +1,15 @@
"""FlowDeck — pytest fixtures and configuration.""" """FlowDeck — pytest fixtures and configuration.
Each test gets a fresh, isolated SQLite database and backup directory so the
suite is safe to run in parallel (``pytest -n auto``): workers never share a
database file, and no state leaks between tests.
"""
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient
@pytest.fixture @pytest.fixture
@@ -15,7 +21,7 @@ def client():
backup_dir = tempfile.mkdtemp(prefix="fd_backups_") backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
# Set env BEFORE importing app modules (config reads at import time) # Set env BEFORE importing app modules (config reads at import time).
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
@@ -23,9 +29,21 @@ def client():
os.environ["BACKUP_DIR"] = backup_dir os.environ["BACKUP_DIR"] = backup_dir
os.environ["PROJECT_SYNC_ENABLED"] = "false" os.environ["PROJECT_SYNC_ENABLED"] = "false"
# Force config reload by clearing the cached Settings instance # IMPORTANT: mutate the existing Settings singleton in place — do NOT rebind
# `app.config.settings`. Modules such as `app.services.backup` and
# `app.routers.auth` hold a direct reference imported at load time, so
# rebinding would leave them pointing at the stale defaults (this was the
# cause of the previously-skipped flaky backup tests).
import app.config import app.config
app.config.settings = app.config.Settings() s = app.config.settings
s.database_url = f"sqlite:///{db_path}"
s.app_secret_key = "test-secret-for-tests"
s.rate_limit_enabled = False
s.backup_enabled = True
s.backup_dir = backup_dir
s.backup_interval_hours = 24
s.backup_keep = 30
s.project_sync_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
@@ -47,6 +65,3 @@ def client():
Path(backup_dir).rmdir() Path(backup_dir).rmdir()
except OSError: except OSError:
pass pass
from fastapi.testclient import TestClient
+72
View File
@@ -207,6 +207,78 @@ def test_tool_workspace_document_crud_and_search(client):
assert "introuvable" in bad.message assert "introuvable" in bad.message
def test_write_blocks_normalizes_llm_blocks(client):
"""LLM blocks are normalized: `text`→`content`, `heading`→`heading_1`,
missing ids are generated, unknown types fall back to paragraph."""
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
doc = asyncio.run(reg.execute("create_document", {"title": "Script"}))
pid = doc.data["document_id"]
bad_blocks = [
{"type": "heading", "text": "test.ps1 — Script de test PowerShell"},
{"type": "paragraph", "text": "# Test script\nWrite-Host 'Hello, World!'"},
{"type": "code", "text": "Write-Host 'hi'", "language": "powershell"},
{"type": "made_up_type", "content": "inconnu"},
]
res = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "blocks": bad_blocks}))
assert res.status == "success", res.message
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
assert row["content_format"] == "blocks"
stored = json.loads(row["content"])
assert stored[0]["type"] == "heading_1", stored[0]
assert stored[0]["content"] == "test.ps1 — Script de test PowerShell"
assert stored[0]["id"], "id must be generated"
assert stored[1]["type"] == "paragraph"
assert stored[1]["content"] == "# Test script\nWrite-Host 'Hello, World!'"
assert stored[2]["type"] == "code"
assert stored[2]["content"] == "Write-Host 'hi'"
assert stored[2]["language"] == "powershell"
assert stored[3]["type"] == "paragraph" # unknown → paragraph
assert stored[3]["content"] == "inconnu"
def test_engine_titles_conversation_on_each_request(client):
"""The conversation title is refreshed after EVERY AI request (not only
while the title is a default placeholder)."""
from app.services.agent_engine import AgentEngine
engine = AgentEngine(_admin_id(), workspace_id=None)
conv = client.post("/api/agent/conversations",
json={"title": "Nouvelle conversation"}).json()["id"]
first = "crée un document « Rapport Q3 »"
asyncio.run(_run_engine(engine, conv, first))
detail = client.get(f"/api/agent/conversations/{conv}").json()
title1 = detail["conversation"]["title"]
assert "Rapport Q3" in title1
second = "crée une collection « Launches »"
asyncio.run(_run_engine(engine, conv, second))
detail = client.get(f"/api/agent/conversations/{conv}").json()
title2 = detail["conversation"]["title"]
assert "Launches" in title2, title2
assert title2 != title1, "title must be updated on the second request too"
def test_suggest_title_strips_composer_prefixes(client):
from app.services.agent_engine import AgentEngine
title = AgentEngine._suggest_title(
"Contexte « test.ps1 »\n\nÉcrire un script de test en powershell dans la page courante",
None,
)
assert title != "Contexte", title
assert "Contexte" not in title
assert title
def test_tool_delete_document_and_undo(client): def test_tool_delete_document_and_undo(client):
from app.db import get_conn from app.db import get_conn
from app.services.agent_engine import undo_action from app.services.agent_engine import undo_action
+17 -2
View File
@@ -19,10 +19,25 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
# A default user id=1 so tests that reference the implicit user (favorites,
# workspace ownership) satisfy foreign keys without relying on leaked state.
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) "
"VALUES (1, 'tester', 'Tester', '[email protected]', 1)"
)
conn.commit()
yield TestClient(app) yield TestClient(app)
os.unlink(db_path) os.unlink(db_path)
@@ -1915,7 +1930,7 @@ def test_nav_menu_workspace_pages(client):
"""/api/nav/menu returns root pages and nested children for a workspace.""" """/api/nav/menu returns root pages and nested children for a workspace."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
u = conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')") conn.execute("INSERT OR IGNORE INTO users (login) VALUES ('navuser')")
uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"] uid = conn.execute("SELECT id FROM users WHERE login='navuser'").fetchone()["id"]
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,)) cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,))
ws_id = cur.lastrowid ws_id = cur.lastrowid
@@ -3294,7 +3309,7 @@ def test_v490_create_comment_with_mentions(client):
json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5}, json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5},
cookies=cookies) cookies=cookies)
assert r.status_code == 200 assert r.status_code == 200
cid = r.json()["id"] r.json()["id"]
r = client.get(f"/api/pages/{pid}/comments", cookies=cookies) r = client.get(f"/api/pages/{pid}/comments", cookies=cookies)
assert r.status_code == 200 assert r.status_code == 200
+1 -1
View File
@@ -159,7 +159,7 @@ def test_automation_scope_collection_filter(client):
}).json()["id"] }).json()["id"]
p_c1 = _make_page(client, c1, props={"Status": "Todo"}) p_c1 = _make_page(client, c1, props={"Status": "Todo"})
p_c2 = _make_page(client, c2, props={"Status": "Todo"}) _make_page(client, c2, props={"Status": "Todo"})
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
+5
View File
@@ -17,6 +17,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
+10 -5
View File
@@ -194,7 +194,8 @@ def test_ws_presence_join_leave(client):
pid = _make_page() pid = _make_page()
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
w_f = wb.receive_json() # welcome w_f = wb.receive_json() # welcome
@@ -211,10 +212,12 @@ def test_ws_cursor_broadcast(client):
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}]) pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}])
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json() wb.receive_json()
wb.receive_json()
wa.receive_json() # peer_join wa.receive_json() # peer_join
wa.send_json({"t": "sel", "block": "a", "offset": 1}) wa.send_json({"t": "sel", "block": "a", "offset": 1})
m = wb.receive_json() m = wb.receive_json()
@@ -229,10 +232,12 @@ def test_ws_title_broadcast(client):
pid = _make_page() pid = _make_page()
_auth_client(client, 1, "tester") _auth_client(client, 1, "tester")
with client.websocket_connect(f"/ws/pages/{pid}") as wa: with client.websocket_connect(f"/ws/pages/{pid}") as wa:
wa.receive_json(); wa.receive_json() wa.receive_json()
wa.receive_json()
_auth_client(client, 2, "other") _auth_client(client, 2, "other")
with client.websocket_connect(f"/ws/pages/{pid}") as wb: with client.websocket_connect(f"/ws/pages/{pid}") as wb:
wb.receive_json(); wb.receive_json() wb.receive_json()
wb.receive_json()
wa.receive_json() wa.receive_json()
wa.send_json({"t": "title", "title": "Nouveau titre"}) wa.send_json({"t": "title", "title": "Nouveau titre"})
m = wb.receive_json() m = wb.receive_json()
+5
View File
@@ -20,6 +20,11 @@ def client():
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["RATE_LIMIT_ENABLED"] = "false"
# Point the process-wide settings singleton at OUR temp DB (xdist-safe).
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db from app.db import init_db
from app.main import app from app.main import app
init_db() init_db()
+203 -9
View File
@@ -148,16 +148,64 @@ def test_onboarding_workspace_and_project(client):
# ═══════════════ Backups ═══════════════ # ═══════════════ Backups ═══════════════
def test_backup_snapshot_and_pruning(client): def test_backup_snapshot_and_pruning(client):
"""Skipped: flaky due to test isolation issues with global config state. """backup_db() snapshots the SQLite file and prune keeps the newest N."""
Passes when run in isolation. import datetime
"""
pytest.skip("Flaky: backup_dir cleanup interference between tests")
def test_backup_admin_api_requires_admin(client): from app.services import backup as backup_svc
"""Skipped: flaky due to test isolation issues with global config state.
Passes when run in isolation. base = datetime.datetime(2026, 1, 1, 0, 0, 0)
""" first = backup_svc.backup_db(now=base)
pytest.skip("Flaky: admin API test interference between tests") assert first and first.startswith("flowdeck-") and first.endswith(".db")
backups = backup_svc.list_backups()
assert len(backups) == 1
assert backups[0]["filename"] == first
assert backups[0]["size"] > 0
# Distinct timestamps → distinct filenames (the format has 1-second resolution).
for i in range(1, 4):
assert backup_svc.backup_db(now=base + datetime.timedelta(seconds=i))
assert len(backup_svc.list_backups()) == 4
removed = backup_svc.prune_old_backups(keep=2)
assert removed == 2
remaining = backup_svc.list_backups()
assert len(remaining) == 2
# Newest first (filename sort == chronological for this format).
assert remaining[0]["filename"] > remaining[1]["filename"]
# Age + due logic (file mtime is "now", so a fresh backup is not due).
assert backup_svc.last_backup_age_hours() is not None
assert backup_svc.backup_due() is False
def test_backup_disabled_returns_none(client):
from app.config import settings
from app.services import backup as backup_svc
previous = settings.backup_enabled
settings.backup_enabled = False
try:
assert backup_svc.backup_db() is None
finally:
settings.backup_enabled = previous
def test_backup_admin_api(client):
"""The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403
# First registered user becomes admin → allowed.
_register(client)
run = client.post("/api/settings/backups/run")
assert run.status_code == 200, run.text
assert run.json()["status"] == "ok"
assert run.json()["filename"].startswith("flowdeck-")
listing = client.get("/api/settings/backups")
assert listing.status_code == 200
assert len(listing.json()["backups"]) >= 1
# ═══════════════ Projects registry ═══════════════ # ═══════════════ Projects registry ═══════════════
@@ -266,6 +314,152 @@ def test_projects_sync_with_mock_client(client):
os.environ.setdefault("PROJECT_SYNC_ENABLED", "false") os.environ.setdefault("PROJECT_SYNC_ENABLED", "false")
# ═══════════════ OAuth integration (mocked providers) ═══════════════
class _FakeProvider:
"""Stand-in OAuth provider — no network calls."""
name = "gitea"
icon = "🔗"
def __init__(self, login="octocat", full_name="Octo Cat", email="[email protected]"):
self.login = login
self.full_name = full_name
self.email = email
def is_enabled(self) -> bool:
return True
def get_authorize_url(self, state, redirect_uri=None, force_login=False):
return f"https://gitea.test/login/oauth/authorize?client_id=cid&state={state}"
async def exchange_code(self, code, redirect_uri=None):
return {"access_token": "tok-123", "refresh_token": "ref-123"}
async def get_user(self, access_token):
return {
"login": self.login,
"full_name": self.full_name,
"email": self.email,
"avatar_url": "https://gitea.test/avatar.png",
"provider_id": "42",
}
async def list_repositories(self, access_token):
return []
def _patch_provider(monkeypatch, provider=None):
from app.auth import providers
fake = provider or _FakeProvider()
monkeypatch.setattr(providers, "get_provider", lambda name: fake if name in ("gitea", "github") else None)
return fake
def _extract_state(location: str) -> str:
from urllib.parse import parse_qs, urlparse
return parse_qs(urlparse(location).query)["state"][0]
def test_oauth_login_callback_flow(client, monkeypatch):
"""Full login flow: authorize redirect → callback → user + token + session."""
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea", follow_redirects=False)
assert login.status_code == 302
assert "gitea.test/login/oauth/authorize" in login.headers["location"]
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "/workspaces" in cb.headers["location"]
assert client.cookies.get("flowdeck_session")
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='gitea_octocat'").fetchone()
assert user and user["auth_method"] == "gitea"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
me = client.get("/auth/user").json()
assert me["authenticated"] is True
assert me["user"]["login"] == "gitea_octocat"
def test_oauth_github_callback_creates_github_user(client, monkeypatch):
_patch_provider(monkeypatch, _FakeProvider(login="hubber", full_name="Hub Ber"))
login = client.get("/auth/login?provider=github", follow_redirects=False)
assert login.status_code == 302
state = _extract_state(login.headers["location"])
cb = client.get(f"/auth/callback?code=xyz&state={state}", follow_redirects=False)
assert cb.status_code == 302
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id, auth_method FROM users WHERE login='github_hubber'").fetchone()
assert user and user["auth_method"] == "github"
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
def test_oauth_link_mode_attaches_to_current_user(client, monkeypatch):
"""mode=link must attach the forge token to the already-logged-in user."""
_register(client)
_patch_provider(monkeypatch)
login = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
state = _extract_state(login.headers["location"])
assert state.endswith(":link")
cb = client.get(f"/auth/callback?code=abc&state={state}", follow_redirects=False)
assert cb.status_code == 302
assert "settings" in cb.headers["location"]
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE login='[email protected]'").fetchone()
token = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user["id"],),
).fetchone()
assert token and token["access_token"] == "tok-123"
# No new OAuth user was created.
count = conn.execute("SELECT COUNT(*) FROM users WHERE login LIKE 'gitea_%'").fetchone()[0]
assert count == 0
def test_oauth_callback_rejects_invalid_state(client, monkeypatch):
_patch_provider(monkeypatch)
client.get("/auth/login?provider=gitea", follow_redirects=False)
bad = client.get("/auth/callback?code=abc&state=tampered", follow_redirects=False)
assert bad.status_code == 400
def test_oauth_provider_authorize_urls():
from app.auth.providers import GiteaProvider, GitHubProvider
gitea = GiteaProvider("https://git.example.com", "cid", "sec", "https://app/auth/callback")
assert gitea.is_enabled()
gitea_url = gitea.get_authorize_url("st", redirect_uri="https://app/auth/callback")
assert gitea_url.startswith("https://git.example.com/login/oauth/authorize?")
assert "client_id=cid" in gitea_url and "state=st" in gitea_url
github = GitHubProvider("cid", "sec", "https://app/auth/callback")
assert github.is_enabled()
assert "github.com/login/oauth/authorize" in github.get_authorize_url("st")
assert not GitHubProvider("", "", "https://app/auth/callback").is_enabled()
# ═══════════════ Multi-user permissions ═══════════════ # ═══════════════ Multi-user permissions ═══════════════
def test_permission_manager_roles(client): def test_permission_manager_roles(client):
+568
View File
@@ -0,0 +1,568 @@
"""FlowDeck — Tests for v5.4.0 (backlinks, duplicate, trash purge, version history, import)
and v5.5.0 (embed, bookmark, video, audio, cover, icon)."""
from __future__ import annotations
import json
import os
import tempfile
# ── Helpers ──────────────────────────────────────────────────────────────
def _login(client):
"""Create admin user and return session cookie + csrf token (like test_app.py)."""
import secrets
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"testadmin_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'Test Admin', ?, ?, 1)",
(login, f"{login}@test.com", ""),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
# get CSRF token
r2 = client.get("/api/csrf-token", cookies={"flowdeck_session": session})
csrf = r2.json()["csrf_token"]
return session, csrf
def _create_workspace(client, cookie, name="Test WS"):
r = client.post("/workspace", json={"name": name}, cookies={"flowdeck_session": cookie})
assert r.status_code == 200
ws = r.json()
client.cookies.set("flowdeck_workspace", str(ws["id"]))
return ws["id"]
def _create_page(client, cookie, csrf, title="Test Page", content="", parent_id=0):
r = client.post(
f"/board/api/pages?title={title}&section=Private&parent_id={parent_id}",
headers={"X-CSRF-Token": csrf},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
return r.json()["id"]
def _save_blocks(client, cookie, csrf, page_id, blocks, title="Test Page"):
r = client.post(
f"/board/api/pages/{page_id}/blocks",
json={"title": title, "blocks": blocks},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
return r.json()
# ── Tests ────────────────────────────────────────────────────────────────
class TestV54Backlinks:
"""v5.4.0: backlinks endpoint."""
def test_backlinks_detects_internal_links(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
# page A
page_a = _create_page(client, cookie, csrf, "Page A")
# page B links to page A via markdown-style internal link
blocks = [{"type": "paragraph", "content": f"See [Page A](/pages/{page_a}) for details"}]
_save_blocks(client, cookie, csrf, page_a, blocks, "Page A")
# page C also links to page A
page_c = _create_page(client, cookie, csrf, "Page C")
blocks_c = [{"type": "paragraph", "content": f"Ref: /pages/{page_a}"}]
_save_blocks(client, cookie, csrf, page_c, blocks_c, "Page C")
# fetch backlinks
r = client.get(
f"/board/api/pages/{page_a}/backlinks",
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
data = r.json()
assert "backlinks" in data
ids = [b["id"] for b in data["backlinks"]]
assert page_c in ids
class TestV54Duplicate:
"""v5.4.0: page duplication (deep copy of content)."""
def test_duplicate_page_copies_blocks(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Original")
blocks = [
{"type": "heading_1", "content": "Title"},
{"type": "paragraph", "content": "Body text"},
{"type": "bulleted_list", "content": "Item 1"},
]
_save_blocks(client, cookie, csrf, page, blocks, "Original")
# duplicate
r = client.post(
f"/board/api/pages/{page}/duplicate",
headers={"X-CSRF-Token": csrf},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
dup = r.json()
assert dup["status"] == "ok"
new_id = dup["id"]
# verify content copied
r2 = client.get(
f"/board/api/pages/{new_id}",
cookies={"flowdeck_session": cookie},
)
assert r2.status_code == 200
new_page = r2.json()
assert new_page["content_format"] == "blocks"
new_blocks = json.loads(new_page["content"])
assert len(new_blocks) == 3
assert new_blocks[0]["type"] == "heading_1"
assert new_blocks[1]["content"] == "Body text"
class TestV54VersionHistory:
"""v5.4.0: version snapshots + list + restore."""
def test_version_snapshot_created_on_save(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Versioned")
blocks_v1 = [{"type": "paragraph", "content": "Version 1"}]
_save_blocks(client, cookie, csrf, page, blocks_v1, "Versioned")
# list versions
r = client.get(
f"/board/api/pages/{page}/versions",
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
data = r.json()
assert len(data["versions"]) >= 1
v1_id = data["versions"][0]["id"]
# modify and save again
blocks_v2 = [{"type": "paragraph", "content": "Version 2"}]
_save_blocks(client, cookie, csrf, page, blocks_v2, "Versioned")
# list again
r2 = client.get(
f"/board/api/pages/{page}/versions",
cookies={"flowdeck_session": cookie},
)
data2 = r2.json()
assert len(data2["versions"]) >= 2
data2["versions"][0]["id"]
# restore v1
r3 = client.post(
f"/board/api/pages/{page}/versions/{v1_id}/restore",
headers={"X-CSRF-Token": csrf},
cookies={"flowdeck_session": cookie},
)
assert r3.status_code == 200
# verify content is back to v1
r4 = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
restored = r4.json()
restored_blocks = json.loads(restored["content"])
assert restored_blocks[0]["content"] == "Version 1"
class TestV54TrashPurge:
"""v5.4.0: global trash 30-day purge."""
def test_trash_purge_removes_old_deleted_pages(self, client):
import datetime
from app.db import get_conn, init_db
from app.services.trash import purge_expired
# create temp DB
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close()
os.environ["DATABASE_URL"] = f"sqlite:///{tmp.name}"
os.environ["APP_SECRET_KEY"] = "test-secret"
os.environ["RATE_LIMIT_ENABLED"] = "false"
os.environ["BACKUP_ENABLED"] = "false"
os.environ["PROJECT_SYNC_ENABLED"] = "false"
import app.config
app.config.settings = app.config.Settings()
init_db()
with get_conn() as conn:
# insert 2 deleted pages: one old, one recent
old = datetime.datetime.utcnow() - datetime.timedelta(days=45)
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5)
cur = conn.execute(
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
("test", "Old Page", old.isoformat()),
)
old_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
("test", "Recent Page", recent.isoformat()),
)
recent_id = cur.lastrowid
conn.commit()
# run purge (30 days)
result = purge_expired(days=30)
assert result["count"] == 1
assert old_id in result["purged"]
with get_conn() as conn:
row = conn.execute("SELECT id FROM pages WHERE id=?", (old_id,)).fetchone()
assert row is None
row = conn.execute("SELECT id FROM pages WHERE id=?", (recent_id,)).fetchone()
assert row is not None
os.unlink(tmp.name)
class TestV54Import:
"""v5.4.0: markdown / file import."""
def test_import_markdown_creates_page(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
md = "# Imported\n\nContent here\n\n- item 1\n- item 2"
r = client.post(
"/board/api/pages/import",
json={"markdown": md, "title": "Imported Page"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert "id" in data
# verify page content
r2 = client.get(
f"/board/api/pages/{data['id']}",
cookies={"flowdeck_session": cookie},
)
page = r2.json()
blocks = json.loads(page["content"])
assert any(b["type"] == "heading_1" and b["content"] == "Imported" for b in blocks)
assert any(b["type"] == "bulleted_list" and "item 1" in b["content"] for b in blocks)
class TestV55CoverIcon:
"""v5.5.0: page cover & icon."""
def test_set_cover_url(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "With Cover")
r = client.post(
f"/board/api/pages/{page}/cover",
json={"cover_url": "https://example.com/cover.png"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
data = r.json()
assert data["cover_url"] == "https://example.com/cover.png"
# verify persisted
r2 = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
assert r2.json().get("cover_url") == "https://example.com/cover.png"
def test_set_page_icon(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "With Icon")
r = client.post(
f"/board/api/pages/{page}/icon",
json={"icon": "🚀"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
data = r.json()
assert data["icon"] == "🚀"
r2 = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
assert r2.json().get("page_icon") == "🚀"
class TestV55EmbedBookmark:
"""v5.5.0: embed, bookmark, video, audio blocks (smoke tests)."""
def test_embed_block_render(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Embed Test")
blocks = [
{"type": "embed", "src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "embed_type": "", "height": 520}
]
_save_blocks(client, cookie, csrf, page, blocks, "Embed Test")
r = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
saved = r.json()
saved_blocks = json.loads(saved["content"])
assert saved_blocks[0]["type"] == "embed"
assert "youtube.com" in saved_blocks[0]["src"]
def test_bookmark_block_render(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Bookmark Test")
blocks = [
{"type": "bookmark", "url": "https://example.com", "title": "Example", "description": "A test site"}
]
_save_blocks(client, cookie, csrf, page, blocks, "Bookmark Test")
r = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
saved = r.json()
saved_blocks = json.loads(saved["content"])
assert saved_blocks[0]["type"] == "bookmark"
assert saved_blocks[0]["url"] == "https://example.com"
def test_video_block_render(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Video Test")
blocks = [{"type": "video", "src": "https://example.com/video.mp4"}]
_save_blocks(client, cookie, csrf, page, blocks, "Video Test")
r = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
saved = r.json()
saved_blocks = json.loads(saved["content"])
assert saved_blocks[0]["type"] == "video"
assert saved_blocks[0]["src"] == "https://example.com/video.mp4"
def test_audio_block_render(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Audio Test")
blocks = [{"type": "audio", "src": "https://example.com/audio.mp3"}]
_save_blocks(client, cookie, csrf, page, blocks, "Audio Test")
r = client.get(
f"/board/api/pages/{page}",
cookies={"flowdeck_session": cookie},
)
saved = r.json()
saved_blocks = json.loads(saved["content"])
assert saved_blocks[0]["type"] == "audio"
assert saved_blocks[0]["src"] == "https://example.com/audio.mp3"
class TestV55PublicPageCoverIcon:
"""v5.5.0: public page includes cover_url and page_icon."""
def test_published_page_includes_cover_icon(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
page = _create_page(client, cookie, csrf, "Public Page")
_save_blocks(client, cookie, csrf, page, [{"type": "paragraph", "content": "Hello"}], "Public Page")
# set cover + icon
client.post(
f"/board/api/pages/{page}/cover",
json={"cover_url": "https://example.com/cover.jpg"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
client.post(
f"/board/api/pages/{page}/icon",
json={"icon": "🌟"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
# publish (via publish endpoint)
r = client.post(
f"/board/api/pages/{page}/publish",
headers={"X-CSRF-Token": csrf},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
slug = r.json()["publish_slug"]
# fetch public page
r2 = client.get(f"/p/{slug}")
assert r2.status_code == 200
html = r2.text
assert "cover.jpg" in html
assert "🌟" in html
class TestV54CollectionDuplicate:
"""v5.4.0: duplicate collection (database)."""
def test_duplicate_collection_copies_structure(self, client):
cookie, csrf = _login(client)
_create_workspace(client, cookie)
# create source collection
r = client.post(
"/db/api",
json={"name": "Source DB", "icon": "📋"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
src_id = r.json()["id"]
# add a property
r = client.post(
f"/db/{src_id}/properties/api",
json={"name": "Status", "prop_type": "select", "options": ["todo", "done"]},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
prop_id = r.json()["id"]
# add a row
r = client.post(
f"/db/{src_id}/pages/api",
json={"title": "Row 1", "properties": {str(prop_id): "todo"}},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
# duplicate
r = client.post(
f"/db/{src_id}/duplicate",
headers={"X-CSRF-Token": csrf},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
dup = r.json()
assert dup["status"] == "duplicated"
new_id = dup["id"]
# verify properties copied
r2 = client.get(
f"/db/{new_id}/properties/api",
cookies={"flowdeck_session": cookie},
)
assert r2.status_code == 200
props = r2.json()["properties"]
assert any(p["name"] == "Status" for p in props)
# verify pages copied
r3 = client.get(
f"/api/collections/{new_id}/table-data",
cookies={"flowdeck_session": cookie},
)
assert r3.status_code == 200
pages = r3.json()["pages"]
assert len(pages) == 1
assert pages[0]["title"] == "Row 1"
class TestV55OGMetadata:
"""v5.5.0: Open Graph metadata endpoint."""
def test_og_metadata_endpoint_exists(self, client):
cookie, csrf = _login(client)
r = client.post(
"/board/api/og/metadata",
json={"url": "https://example.com"},
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
cookies={"flowdeck_session": cookie},
)
# endpoint exists, might return ok or error depending on network
assert r.status_code in (200, 400, 500, 502, 503, 504)
class TestExportNewBlocks:
"""Export service renders new block types."""
def test_blocks_to_markdown_includes_new_types(self):
from app.services.export import blocks_to_markdown
blocks = [
{"type": "video", "src": "https://ex.com/v.mp4"},
{"type": "audio", "src": "https://ex.com/a.mp3"},
{"type": "bookmark", "url": "https://ex.com", "title": "Ex"},
{"type": "embed", "src": "https://youtube.com/watch?v=abc", "embed_type": ""},
]
md = blocks_to_markdown(blocks)
assert "[Video]" in md
assert "[Audio]" in md
assert "[Ex](https://ex.com)" in md or "https://ex.com" in md
assert "https://youtube.com/watch?v=abc" in md
def test_blocks_to_html_includes_new_types(self):
from app.services.export import blocks_to_html
blocks = [
{"type": "video", "src": "https://ex.com/v.mp4"},
{"type": "audio", "src": "https://ex.com/a.mp3"},
{"type": "bookmark", "url": "https://ex.com", "title": "Ex", "description": "Desc", "image": "img.jpg", "site_name": "Example"},
{"type": "embed", "src": "https://youtube.com/watch?v=abc", "embed_type": "", "height": 400},
]
html = blocks_to_html(blocks)
assert "<video" in html
assert "<audio" in html
assert "Ex" in html # bookmark title appears in rendered HTML
assert "iframe" in html
class TestDashboardPublicNewBlocks:
"""Public page renderer (_render_blocks_public) for new block types."""
def test_render_blocks_public_video_audio_bookmark_embed(self):
from app.routers.dashboard import _render_blocks_public
blocks = [
{"type": "video", "src": "https://ex.com/v.mp4"},
{"type": "audio", "src": "https://ex.com/a.mp3"},
{"type": "bookmark", "url": "https://ex.com", "title": "Ex", "description": "Desc", "image": "img.jpg", "site_name": "Example"},
{"type": "embed", "src": "https://youtube.com/watch?v=abc", "embed_type": "", "height": 400},
]
html = _render_blocks_public(blocks)
assert "<video" in html
assert "<audio" in html
assert "bookmark" in html or "Ex" in html
assert "iframe" in html