Merge pull request 'feat(v5.2.0): Infrastructure & Polish - isolation tests, xdist, lint, CI' (#15) from feat/v5.4.0-v5.5.0 into main
This commit was merged in pull request #15.
This commit is contained in:
@@ -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
@@ -1,12 +1,26 @@
|
||||
name: FlowDeck CI
|
||||
|
||||
on:
|
||||
# Run on every pushed branch so feature branches are validated before the PR.
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
run: ruff check app tests
|
||||
- name: ESLint (JavaScript)
|
||||
run: npx --yes eslint static/js
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
|
||||
@@ -19,7 +33,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |
|
||||
run: |-
|
||||
SUDO=""
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||
$SUDO apt-get update
|
||||
@@ -27,13 +41,14 @@ jobs:
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
|
||||
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-cov
|
||||
- name: Run tests with coverage
|
||||
run: pip install -r requirements-dev.txt pytest-cov
|
||||
- name: Run tests (parallel) with coverage
|
||||
env:
|
||||
GITEA_URL: https://git.dracodev.net
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
APP_SECRET_KEY: ci-test-key
|
||||
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
|
||||
# `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
|
||||
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: coverage report -m || true
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# 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)
|
||||
|
||||
> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne
|
||||
|
||||
+40
-28
@@ -437,40 +437,51 @@ app/
|
||||
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
|
||||
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
|
||||
|
||||
### v5.2.0 — Infrastructure & Polish 🔄 (en cours)
|
||||
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` +
|
||||
> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau
|
||||
> 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é.
|
||||
### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
|
||||
> **Objectif** : fondations de production — design system, sécurité, infra, forge.
|
||||
> **COMPLETED**.
|
||||
|
||||
**Design system**
|
||||
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés)
|
||||
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts
|
||||
- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
|
||||
- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
|
||||
|
||||
**Sécurité & Utilisateur**
|
||||
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur *(PARTIEL : `public_api.py` + tokens existent, manque la gestion UI dans Settings)*
|
||||
- [ ] **Sessions actives** — voir et révoquer les sessions
|
||||
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
|
||||
- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
|
||||
- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
|
||||
- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
|
||||
|
||||
**Infrastructure**
|
||||
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅
|
||||
- [ ] **Backup automatique** — cron daily → fichier daté
|
||||
- [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)*
|
||||
- [ ] **Tests parallèles** — pytest-xdist
|
||||
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
||||
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
|
||||
- [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
|
||||
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
|
||||
- [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
|
||||
- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
|
||||
- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
|
||||
|
||||
**Forge integration**
|
||||
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
|
||||
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure)
|
||||
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
||||
- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
|
||||
- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
|
||||
- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
|
||||
|
||||
**Tests (cibles)**
|
||||
- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅
|
||||
- [ ] Tests d'intégration auth (OAuth mock)
|
||||
- [ ] Tests des adapters forge (mock HTTP)
|
||||
- [ ] Tests multi-user (permissions croisées)
|
||||
- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
|
||||
- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
|
||||
- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
|
||||
- [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
|
||||
|
||||
**✅ Validation (2026-09-11)** :
|
||||
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
|
||||
- `pytest -q` (suite complète) → **397 passed**
|
||||
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
|
||||
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
|
||||
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
|
||||
|
||||
**Complétion du 2026-09-11** :
|
||||
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
|
||||
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
|
||||
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
|
||||
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
|
||||
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
|
||||
|
||||
### v5.3.0 — Database Avancée ✅ (2026-09-06)
|
||||
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
|
||||
@@ -616,11 +627,12 @@ app/
|
||||
|
||||
## 🎯 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`)
|
||||
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)
|
||||
2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 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)
|
||||
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
|
||||
@@ -639,4 +651,4 @@ Quality DB views, Agent IA Palette → Realtime + S
|
||||
DB avancée, redo, drag&drop,
|
||||
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,3 +1,5 @@
|
||||
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
|
||||
from app.auth.oauth import GiteaOAuth
|
||||
from app.auth.session import SessionManager, get_current_user
|
||||
|
||||
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
|
||||
|
||||
@@ -817,6 +817,11 @@ def init_db():
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
||||
# schema exists without depending on the FastAPI lifespan startup.
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
init_webhook_tables()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
|
||||
+8
-4
@@ -81,13 +81,17 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.projects import 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:
|
||||
yield
|
||||
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()
|
||||
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:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
@@ -96,7 +100,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.9.1",
|
||||
version="5.11.1",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
"""FlowDeck — Custom middleware."""
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
|
||||
__all__ = ["CSRFMiddleware"]
|
||||
|
||||
@@ -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")
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
|
||||
@@ -178,7 +178,7 @@ async def create_agent(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -466,9 +466,9 @@ async def undo(request: Request, action_id: int):
|
||||
try:
|
||||
undo_action(action_id)
|
||||
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
|
||||
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"}
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ async def create_skill(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -149,7 +149,7 @@ async def move_card(
|
||||
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
status_labels = await _get_status_labels(owner, repo, board_id)
|
||||
filtered_names = [l for l in current_labels if l not in status_labels]
|
||||
filtered_names = [name for name in current_labels if name not in status_labels]
|
||||
filtered_names.append(mapping["gitea_label"])
|
||||
|
||||
# Resolve label names to IDs
|
||||
@@ -293,7 +293,7 @@ async def create_issue(
|
||||
if not _check_rate_limit(request):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
|
||||
milestone_id = int(milestone) if milestone.strip().isdigit() else None
|
||||
|
||||
issue = await gitea.create_issue(
|
||||
@@ -345,7 +345,7 @@ async def update_issue_api(
|
||||
if state:
|
||||
kwargs["state"] = state
|
||||
if labels:
|
||||
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
|
||||
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
|
||||
if milestone and milestone.strip().isdigit():
|
||||
kwargs["milestone"] = int(milestone)
|
||||
if assignee:
|
||||
@@ -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)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
|
||||
|
||||
# Get checklists from local DB
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -53,7 +53,7 @@ def _validate_payload(body: dict) -> None:
|
||||
else:
|
||||
json.dumps(val)
|
||||
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")
|
||||
|
||||
+440
-21
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
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':
|
||||
return 'edit'
|
||||
n = name.lower()
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
|
||||
if n.endswith('.pdf'): return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n): return 'edit'
|
||||
if n.endswith('.py'): return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n): return 'file'
|
||||
if n.endswith('.css'): return 'file'
|
||||
if n.endswith('.json'): return 'file'
|
||||
if n.endswith('.sql'): return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
|
||||
if n.endswith('.ps1'): return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file'
|
||||
if re.search(r'\.(txt|log)$', n): return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
|
||||
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
|
||||
return 'image'
|
||||
if n.endswith('.pdf'):
|
||||
return 'file'
|
||||
if re.search(r'\.(md|markdown)$', n):
|
||||
return 'edit'
|
||||
if n.endswith('.py'):
|
||||
return 'file'
|
||||
if re.search(r'\.(js|jsx|ts|tsx)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(html?|xml)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.css'):
|
||||
return 'file'
|
||||
if n.endswith('.json'):
|
||||
return 'file'
|
||||
if n.endswith('.sql'):
|
||||
return 'file'
|
||||
if re.search(r'\.(sh|bash|zsh)$', n):
|
||||
return 'file'
|
||||
if n.endswith('.ps1'):
|
||||
return 'file'
|
||||
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(txt|log)$', n):
|
||||
return 'file'
|
||||
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
|
||||
return 'file'
|
||||
return 'file'
|
||||
|
||||
|
||||
@@ -677,6 +692,33 @@ async def update_share(request: Request, page_id: int):
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
import secrets
|
||||
slug = "p-" + secrets.token_urlsafe(8)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
@@ -820,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property already exists: {e}")
|
||||
raise HTTPException(409, f"Property already exists: {e}") from e
|
||||
return {"status": "ok", "name": name, "type": prop_type}
|
||||
|
||||
|
||||
@@ -874,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
if not issue.get("pull_request"):
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
raise HTTPException(500, str(e)) from e
|
||||
return {"status": "ok", "issues_scanned": len(issues)}
|
||||
|
||||
|
||||
@@ -945,13 +987,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
"""Save blocks JSON content (Notion-style block editor)."""
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks_json = json.dumps(body.get("blocks", []))
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
|
||||
with get_conn() as conn:
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
@@ -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=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
|
||||
"""Insert a version snapshot unless it is byte-identical to the latest one."""
|
||||
prev = conn.execute(
|
||||
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
|
||||
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if prev is not None and prev["blocks_json"] == blocks_json:
|
||||
if prev["title"] != (title or ""):
|
||||
conn.execute(
|
||||
"UPDATE page_versions SET title=? WHERE id="
|
||||
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
|
||||
(title or "", page_id),
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
|
||||
(page_id, user_id, title or "", blocks_json),
|
||||
)
|
||||
|
||||
|
||||
def _block_texts(b: dict) -> list[str]:
|
||||
"""Flatten a block (including children) into searchable text chunks."""
|
||||
out = []
|
||||
raw = b.get("content")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
out.append(raw)
|
||||
for child in b.get("children") or []:
|
||||
out.extend(_block_texts(child))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
async def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, content, content_format, updated_at "
|
||||
"FROM pages WHERE deleted_at IS NULL AND id != ?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fmt = r["content_format"]
|
||||
hits = False
|
||||
if fmt == "blocks" and r["content"]:
|
||||
try:
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and f"/pages/{page_id}" in text:
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
elif fmt == "markdown":
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
elif r["content"]:
|
||||
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
backlinks.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled",
|
||||
"workspace": r["workspace"] or "",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
})
|
||||
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return {"backlinks": backlinks}
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
async def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
|
||||
"COALESCE(u.login, '') AS author "
|
||||
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
|
||||
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"versions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
|
||||
(version_id, page_id),
|
||||
).fetchone()
|
||||
if not ver:
|
||||
raise HTTPException(404, "Version not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
# ═══════════ v5.4.0: Page & collection duplication ═══════════
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
page = dict(row)
|
||||
|
||||
def copy_tree(src_id: int, parent_id) -> int:
|
||||
with get_conn() as conn:
|
||||
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||||
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
|
||||
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
|
||||
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
|
||||
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
|
||||
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
|
||||
(parent_id, src_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
for child in conn.execute(
|
||||
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
|
||||
).fetchall():
|
||||
copy_tree(child["id"], new_id)
|
||||
return new_id
|
||||
|
||||
new_id = copy_tree(page_id, page.get("parent_id"))
|
||||
title = (page.get("title") or "Untitled") + " copy"
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
# ═══════════ v5.5.0: Cover & icon ═══════════
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
ws_id = int(cookie)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if row and row["workspace_id"]:
|
||||
return int(row["workspace_id"])
|
||||
return 1
|
||||
|
||||
|
||||
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
|
||||
{file_url, file_path, mime_type, size, file_name}."""
|
||||
import datetime
|
||||
import re as _re
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
|
||||
return {
|
||||
"file_url": f"/api/files/{ws_id}/{final}",
|
||||
"file_path": f"uploads/workspace_{ws_id}/{final}",
|
||||
"mime_type": mime,
|
||||
"size": (folder / final).stat().st_size,
|
||||
"file_name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/cover")
|
||||
async def set_page_cover(request: Request, page_id: int):
|
||||
"""v5.5.0: upload an image cover for a page.
|
||||
|
||||
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
|
||||
an image stored in the workspace's uploads directory.
|
||||
"""
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if ctype.startswith("application/json"):
|
||||
body = await request.json()
|
||||
cover_url = (body.get("cover_url") or "").strip()
|
||||
if not cover_url:
|
||||
raise HTTPException(400, "cover_url required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
|
||||
ws_id = _ws_id_for(request, page_id)
|
||||
meta = await _store_uploaded_file(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
async def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok", "page_id": page_id}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
"""v5.5.0: set a page emoji/icon label."""
|
||||
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")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
- workspace_id: move page to a different workspace
|
||||
- parent_id: change parent (0 = root level)
|
||||
@@ -1053,7 +1472,7 @@ async def view_page(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
@@ -1121,4 +1540,4 @@ async def sync_project(owner: str, repo: str):
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
raise HTTPException(500, str(e)) from e
|
||||
|
||||
@@ -50,7 +50,7 @@ def _serialize(rows):
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
"""List page-level and inline comments for a FlowDeck page."""
|
||||
user = _current_user(request)
|
||||
_current_user(request)
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
|
||||
+147
-9
@@ -223,6 +223,144 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
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) ──
|
||||
|
||||
|
||||
@@ -415,7 +553,7 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
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"}
|
||||
|
||||
@@ -841,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
new_status = body.get("new_status", "Done")
|
||||
body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
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 {
|
||||
"id": cur.lastrowid,
|
||||
@@ -970,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int):
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
new_workspace_id = body.get("workspace_id")
|
||||
body.get("workspace_id")
|
||||
|
||||
with get_conn() as conn:
|
||||
source = conn.execute(
|
||||
@@ -1202,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
|
||||
)
|
||||
conn.commit()
|
||||
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"}
|
||||
|
||||
|
||||
@@ -1465,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config:
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for k, v in props.items():
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
@@ -1535,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
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 "..." in v:
|
||||
parts = v.split("...")
|
||||
@@ -1708,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
lat, lng = None, None
|
||||
for k, v in props.items():
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and "," in v:
|
||||
parts = v.split(",")
|
||||
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", "{}"))
|
||||
group = 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 "→" in v:
|
||||
parts = v.split("→")
|
||||
|
||||
+82
-13
@@ -217,7 +217,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
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"])
|
||||
|
||||
return {
|
||||
sidebar = {
|
||||
"workspace_name": ws, "workspace_initial": initial,
|
||||
"active_ws_name": active_ws_name,
|
||||
"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)
|
||||
async def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
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")
|
||||
async def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
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
|
||||
if not ws_id:
|
||||
return {"tree": [], "breadcrumb": []}
|
||||
uid = _get_user_id(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
if folder:
|
||||
# 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)
|
||||
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
||||
else:
|
||||
# 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}
|
||||
|
||||
|
||||
@@ -1028,11 +1029,11 @@ async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
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."""
|
||||
if parent_id is None:
|
||||
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, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"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()
|
||||
else:
|
||||
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, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"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
|
||||
all_ids = [r["id"] for r in rows]
|
||||
tags_map = {}
|
||||
favorited_ids = set()
|
||||
if all_ids:
|
||||
placeholders = ",".join("?" for _ in all_ids)
|
||||
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({
|
||||
"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 = []
|
||||
for r in rows:
|
||||
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
|
||||
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",
|
||||
"is_folder": is_folder,
|
||||
"content_format": r["content_format"] if not is_folder else None,
|
||||
"page_icon": r["page_icon"] or "",
|
||||
"children": children,
|
||||
"has_children": len(children) > 0,
|
||||
"child_count": len(children),
|
||||
@@ -1104,6 +1113,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
|
||||
"author": author,
|
||||
"tags": tags_map.get(r["id"], []),
|
||||
"is_shared": bool(r["is_shared"]),
|
||||
"favorited": r["id"] in favorited_ids,
|
||||
})
|
||||
return tree
|
||||
|
||||
@@ -1955,7 +1965,7 @@ async def update_account(request: Request):
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
async def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
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:
|
||||
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",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
@@ -2060,6 +2070,8 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
content_html=content_html,
|
||||
updated_at=page.get("updated_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", "")
|
||||
html_parts.append(
|
||||
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>'
|
||||
)
|
||||
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:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
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")
|
||||
async def api_convert_to_database(page_id: int, request: Request):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
|
||||
@@ -74,10 +74,10 @@ async def export_pdf(page_id: int, request: Request):
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
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
|
||||
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")
|
||||
headers = _download_header(filename, "application/pdf")
|
||||
return Response(content=pdf_bytes, status_code=200, headers=headers)
|
||||
|
||||
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
|
||||
def _require_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401.
|
||||
|
||||
|
||||
Only returns a client if the user has personally connected their Gitea
|
||||
account (OAuth token). No fallback to admin token — each user must link
|
||||
their own Gitea account to see Gitea projects.
|
||||
@@ -160,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {"labels": [
|
||||
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
|
||||
for l in labels
|
||||
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
|
||||
for lbl in labels
|
||||
]}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
@@ -327,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
for label in labels:
|
||||
name = label.get("name", "")
|
||||
color = label.get("color", "#787774")
|
||||
if not name: continue
|
||||
if not name:
|
||||
continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
||||
).fetchone()
|
||||
|
||||
+58
-16
@@ -59,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
fn = title.lower()
|
||||
if fn.endswith(".pdf"): icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
else:
|
||||
icon = "📝"
|
||||
|
||||
@@ -69,6 +72,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
@@ -84,6 +88,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"content_format": content_format,
|
||||
"favorited": bool(db_row.get("favorited", 0)),
|
||||
"share_mode": db_row.get("share_mode", "private"),
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +103,30 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
"""Batch-load page tags for library items."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
tag_map: dict = {}
|
||||
for r in rows:
|
||||
tag_map.setdefault(r["page_id"], []).append({
|
||||
"id": r["id"], "name": r["name"], "color": r["color"],
|
||||
})
|
||||
for it in items:
|
||||
it["tags"] = tag_map.get(it["id"], [])
|
||||
return items
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
@@ -124,7 +152,7 @@ def _enrich_children(items: list) -> list:
|
||||
|
||||
BASE_SELECT = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section"
|
||||
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
|
||||
)
|
||||
|
||||
|
||||
@@ -307,7 +335,7 @@ async def library_private(
|
||||
@router.get("/local-workspace-children/{item_id:int}")
|
||||
async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"""Return children of a local workspace item for tree expansion."""
|
||||
uid = _get_user_id(request)
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
@@ -333,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"): icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
with get_conn() as conn:
|
||||
child_count = conn.execute(
|
||||
@@ -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']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
@@ -441,9 +474,12 @@ async def library_local_workspace(
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"): icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
# Check for children
|
||||
child_count = conn.execute(
|
||||
@@ -469,6 +505,8 @@ async def library_local_workspace(
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
@@ -476,10 +514,14 @@ async def library_local_workspace(
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if not size_bytes: return ""
|
||||
if size_bytes < 1024: return f"{size_bytes} B"
|
||||
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB"
|
||||
if not size_bytes:
|
||||
return ""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1048576:
|
||||
return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824:
|
||||
return f"{size_bytes/1048576:.1f} MB"
|
||||
return f"{size_bytes/1073741824:.1f} GB"
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
|
||||
@@ -107,7 +107,7 @@ async def set_prefs(request: Request):
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
user = _current_user(request)
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
|
||||
@@ -201,7 +201,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
user = _require_auth(request)
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
|
||||
@@ -86,7 +86,7 @@ async def save_sidebar_config(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
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")
|
||||
if not config or not isinstance(config, dict):
|
||||
|
||||
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
|
||||
return {"status": "ok", "webhook": result}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
|
||||
@@ -512,13 +512,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
conn.execute(
|
||||
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
|
||||
(sid, page_id, status_at_start, velocity_points),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, "Page already assigned to this sprint")
|
||||
raise HTTPException(409, "Page already assigned to this sprint") from None
|
||||
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ MAX_ITERATIONS = 12
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
|
||||
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
|
||||
- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout.
|
||||
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
|
||||
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
|
||||
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
|
||||
@@ -151,12 +152,21 @@ class AgentEngine:
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
try:
|
||||
suggested = self._suggest_title(objective, None)
|
||||
if suggested:
|
||||
self._update_conversation(conversation_id, title=suggested[:80])
|
||||
except Exception: # noqa: BLE001 — never break a run because of the title
|
||||
logger.exception("Auto-title failed for conversation #%s", conversation_id)
|
||||
|
||||
tool_schema = self.tools.schema(scope)
|
||||
final_text = None
|
||||
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
|
||||
|
||||
try:
|
||||
for step in range(settings.agent_max_iterations or MAX_ITERATIONS):
|
||||
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
|
||||
if self._tokens >= settings.agent_max_tokens_budget:
|
||||
yield self._event("error", {"message": "Budget de tokens dépassé"})
|
||||
break
|
||||
@@ -328,21 +338,11 @@ class AgentEngine:
|
||||
|
||||
# ── 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):
|
||||
"""Rename a freshly created conversation with a descriptive title derived
|
||||
from its content. Only runs while the title is still a default placeholder,
|
||||
so a user-renamed conversation is never overwritten."""
|
||||
"""Rename the conversation with a descriptive title derived from the
|
||||
*latest* user request. Runs after every AI call so the history list
|
||||
always reflects the current topic and stays easy to browse."""
|
||||
try:
|
||||
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)
|
||||
if suggested:
|
||||
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:
|
||||
"""Produce a short descriptive title from the user objective (offline-safe)."""
|
||||
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
# Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the
|
||||
# frontend prepends before the real user text.
|
||||
text = re.sub(
|
||||
r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*",
|
||||
"", text,
|
||||
).strip()
|
||||
if not text:
|
||||
return "Conversation"
|
||||
low = text.lower()
|
||||
|
||||
@@ -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
@@ -243,7 +243,8 @@ def _parse_table_at(lines: list[str], i: int, n: int):
|
||||
rows.append(cells)
|
||||
j += 1
|
||||
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]
|
||||
return (
|
||||
{
|
||||
@@ -464,6 +465,11 @@ def _page_markdown_source(page: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def markdown_to_blocks(md: str) -> list:
|
||||
"""Public wrapper around the GFM→blocks parser (used by page import)."""
|
||||
return _md_to_blocks(md)
|
||||
|
||||
|
||||
# ═══════════════ Markdown ═══════════════
|
||||
|
||||
def blocks_to_markdown(blocks: list) -> str:
|
||||
@@ -508,6 +514,20 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "video":
|
||||
out.append(f"[Video]({b.get('src') or ''})")
|
||||
elif t == "audio":
|
||||
out.append(f"[Audio]({b.get('src') or ''})")
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = (b.get("title") or "").strip()
|
||||
out.append(f"[{title or url}]({url})" if title else url)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
if b.get("embed_type") in ("pdf", "download", None, ""):
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
else:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
else:
|
||||
@@ -609,7 +629,54 @@ def blocks_to_html(blocks: list) -> str:
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
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":
|
||||
parts.append(_table_to_html(b))
|
||||
else:
|
||||
|
||||
@@ -94,7 +94,7 @@ def _do_mentions(conn, handles, actor_id, ntype, title, message,
|
||||
resource_type, resource_id, url, notified):
|
||||
placeholders = ",".join("?" * len(handles))
|
||||
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,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -479,17 +479,90 @@ class UpdatePage(Tool):
|
||||
class WriteBlocks(Tool):
|
||||
name = "write_blocks"
|
||||
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 = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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)"},
|
||||
},
|
||||
"required": ["page_id"],
|
||||
}
|
||||
|
||||
_VALID_BLOCK_TYPES = frozenset({
|
||||
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
|
||||
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
|
||||
"callout", "table_of_contents", "math", "columns", "divider",
|
||||
"code", "table", "button", "image", "embed", "bookmark",
|
||||
"video", "audio", "meeting",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _normalize_blocks(cls, blocks: list) -> list:
|
||||
"""Normalize blocks coming from the LLM so the editor can render them.
|
||||
|
||||
Common LLM mistakes handled:
|
||||
- ``text`` instead of ``content``
|
||||
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
|
||||
- ``bullet`` / ``list`` instead of ``bulleted_list``
|
||||
- ``numbered`` instead of ``numbered_list``
|
||||
- ``check`` / ``checkbox`` instead of ``to_do``
|
||||
- ``code_block`` instead of ``code``
|
||||
- ``h1`` … ``h6`` shorthand
|
||||
- Missing ``id`` fields (editor assigns them on load but we add them
|
||||
to keep the JSON self-contained)
|
||||
"""
|
||||
import uuid
|
||||
|
||||
_TYPE_MAP = {
|
||||
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
|
||||
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
|
||||
"h6": "heading_4",
|
||||
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
|
||||
"numbered": "numbered_list", "numbered_list": "numbered_list",
|
||||
"check": "to_do", "checkbox": "to_do", "task": "to_do",
|
||||
"code_block": "code",
|
||||
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
|
||||
}
|
||||
|
||||
out = []
|
||||
for block in (blocks or []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
b = dict(block)
|
||||
# --- normalize type ---
|
||||
raw_type = (b.get("type") or "paragraph").strip().lower()
|
||||
btype = _TYPE_MAP.get(raw_type, raw_type)
|
||||
if btype not in cls._VALID_BLOCK_TYPES:
|
||||
btype = "paragraph"
|
||||
b["type"] = btype
|
||||
# --- normalize content field (LLM often sends "text" instead of "content") ---
|
||||
if "content" not in b and "text" in b:
|
||||
b["content"] = b.pop("text")
|
||||
elif "content" not in b:
|
||||
# Try other common fields
|
||||
for alt in ("value", "body", "source"):
|
||||
if alt in b:
|
||||
b["content"] = b.pop(alt)
|
||||
break
|
||||
else:
|
||||
b.setdefault("content", "")
|
||||
# --- generate id if missing ---
|
||||
if not b.get("id"):
|
||||
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
pid = args.get("page_id")
|
||||
has_blocks = "blocks" in args
|
||||
@@ -506,9 +579,10 @@ class WriteBlocks(Tool):
|
||||
"title": page["title"]}
|
||||
final_title = new_title or page["title"]
|
||||
if has_blocks:
|
||||
normalized = self._normalize_blocks(args["blocks"])
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(args["blocks"], ensure_ascii=False), final_title, pid),
|
||||
(json.dumps(normalized, ensure_ascii=False), final_title, pid),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
|
||||
|
||||
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
|
||||
window (default 30 days, matching the UI copy in ``trash.html``), then are
|
||||
permanently deleted together with their child pages and orphaned versions.
|
||||
The purge is run from a background scheduler in ``app.main`` so it is fully
|
||||
automatic (no per-request cost).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime | None:
|
||||
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
|
||||
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
s = value[:19].replace("T", " ")
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not ISO_RE.match(value):
|
||||
return None
|
||||
return dt
|
||||
|
||||
|
||||
def purge_expired(days: int = 30) -> dict:
|
||||
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
|
||||
|
||||
Children are re-parented to their grandparent before deletion in order
|
||||
to avoid silently dropping whole sub-trees; deleted pages are removed
|
||||
together with their ``page_versions`` (FK cascade).
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
dt = _parse_dt(row["deleted_at"])
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
page_id = row["id"]
|
||||
# Promote direct children to the deleted page's parent so no live
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
|
||||
"WHERE parent_id=?",
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
|
||||
return {"purged": purged, "count": len(purged)}
|
||||
|
||||
|
||||
async def trash_purge_scheduler(interval_hours: int = 24):
|
||||
"""Background loop purging the trash once a day (idempotent, cheap when
|
||||
there is nothing expired)."""
|
||||
import asyncio
|
||||
|
||||
# Run once shortly after startup, then on the configured interval.
|
||||
await asyncio.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
purge_expired(days=30)
|
||||
except Exception as exc: # pragma: no cover - defensive only
|
||||
logger.warning("Trash purge failed: %s", exc)
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
@@ -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>
|
||||
@@ -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 / Publish Dialog ═══════════ -->
|
||||
@@ -250,6 +254,46 @@
|
||||
|
||||
<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 -->
|
||||
<div class="sd-footer">
|
||||
<a href="#" class="sd-footer-link" @click.prevent
|
||||
@@ -395,6 +439,15 @@
|
||||
@click.outside="!_justOpened && (moreOpen = false)"
|
||||
x-transition
|
||||
>
|
||||
<div class="more-menu-item" @click="toggleBacklinks">
|
||||
{{ fd_icon("link-2",14) }} Linked from
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleVersions">
|
||||
{{ fd_icon("history",14) }} Version history
|
||||
</div>
|
||||
<div class="more-menu-item" @click="toggleImport">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
|
||||
<template x-if="exportOpen">
|
||||
<div class="more-menu-sub">
|
||||
@@ -439,21 +492,23 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Page Content ═══════════ -->
|
||||
<div class="page-cover-area">
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
|
||||
<img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -669,6 +724,97 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
@click.outside="versionsOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('history',14) }} Version history</span>
|
||||
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!versionsLoading && versionsList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
|
||||
</template>
|
||||
<template x-for="v in versionsList" :key="v.id">
|
||||
<div class="version-item"
|
||||
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
|
||||
@click="restoreVersion(v.id)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
|
||||
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
|
||||
<div class="backlinks-popover"
|
||||
x-show="backlinksOpen"
|
||||
@click.outside="backlinksOpen = false"
|
||||
x-transition.opacity.scale.origin.top.right
|
||||
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
|
||||
<span>{{ fd_icon('link-2',14) }} Linked from</span>
|
||||
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow-y:auto;">
|
||||
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
|
||||
<template x-if="!backlinksLoading && backlinksList.length === 0">
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
|
||||
<div class="import-modal"
|
||||
x-show="importOpen"
|
||||
@click.self="importOpen = false"
|
||||
x-transition.opacity
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
|
||||
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
|
||||
<button class="flowdeck-modal-close" @click="importOpen = false">×</button>
|
||||
</div>
|
||||
<div style="padding:20px;overflow-y:auto;flex:1;">
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
<!-- Markdown text import -->
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
|
||||
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import as new page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script type="application/json" id="page-data">
|
||||
{{ page_data | tojson }}
|
||||
</script>
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
@@ -14,7 +14,11 @@
|
||||
{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:'➡️'},
|
||||
]},
|
||||
{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:'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) ──
|
||||
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'};
|
||||
// ── Hover preview card (matches Notion's visual hint) ──
|
||||
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 '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 '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>');
|
||||
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==='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==='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==='table_of_contents')return renderTOC(b);
|
||||
if(b.type==='math')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
if(b.type==='columns')return renderColumnsBlock(b);
|
||||
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==='video'){
|
||||
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==='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.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>`;
|
||||
@@ -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';
|
||||
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...');
|
||||
let inner='';
|
||||
@@ -865,6 +890,18 @@
|
||||
_commentSel:{range:null,bid:null,start:0,end:0},
|
||||
_notifiedMentions:{},
|
||||
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() {
|
||||
if (!this.updatedAt) return 'just now';
|
||||
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>';}}
|
||||
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);},
|
||||
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);},
|
||||
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);},
|
||||
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;},
|
||||
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);},
|
||||
|
||||
// ── 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 ──
|
||||
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;}
|
||||
const block=this.blocks[idx];this.sync();this.pushHistory();block.content='';
|
||||
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='';}}
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||
|
||||
+313
-12
@@ -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: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 */
|
||||
.fd-ap-empty{padding:6px 2px 4px}
|
||||
.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-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 */
|
||||
.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}
|
||||
@@ -199,7 +231,7 @@
|
||||
<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>
|
||||
<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>
|
||||
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
|
||||
@@ -223,7 +255,11 @@
|
||||
<template x-if="tab==='history'">
|
||||
<div>
|
||||
<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 style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
|
||||
</div>
|
||||
@@ -253,6 +289,38 @@
|
||||
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
||||
<div class="fd-agent-msg-body">
|
||||
<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 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>
|
||||
@@ -276,7 +344,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</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-err" x-show="errorMsg" x-text="errorMsg"></div>
|
||||
</div>
|
||||
@@ -372,6 +440,18 @@
|
||||
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -423,6 +503,9 @@
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||
_livePh: null,
|
||||
previewVisible: false, previewTitle: '', previewMeta: '', previewStart: '', previewEnd: '',
|
||||
_previewCache: null, _previewTimer: null, _longPressTimer: null, _suppressNextClick: false,
|
||||
_slashSeq: 0,
|
||||
|
||||
init(){
|
||||
@@ -498,6 +581,7 @@
|
||||
if(!this.open) return;
|
||||
this.open = false;
|
||||
this.modelOpen = false;
|
||||
this.hidePreview();
|
||||
if(this._applyState) this._applyState();
|
||||
},
|
||||
toggle(){ this.open ? this.close() : this.openAgent(); },
|
||||
@@ -740,6 +824,7 @@
|
||||
var self = this;
|
||||
this.tab='chat';
|
||||
this.errorMsg = '';
|
||||
this.hidePreview();
|
||||
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
|
||||
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}; });
|
||||
@@ -747,8 +832,119 @@
|
||||
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; }
|
||||
self.applyModels();
|
||||
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(){});
|
||||
},
|
||||
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(){
|
||||
var self = this;
|
||||
var body = {title:'Nouvelle conversation'};
|
||||
@@ -1592,6 +1788,16 @@
|
||||
}).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 ──
|
||||
async send(){
|
||||
var self = this;
|
||||
@@ -1642,6 +1848,23 @@
|
||||
|
||||
self.streaming = true;
|
||||
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.scrollBottom();
|
||||
|
||||
@@ -1673,19 +1896,31 @@
|
||||
if(!line) return;
|
||||
try{
|
||||
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'){
|
||||
var msg = ev.status==='success'
|
||||
? (ev.tool+' ✓ '+(ev.message||''))
|
||||
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
|
||||
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
|
||||
addStep(agentPanel._actionStep(ev));
|
||||
}
|
||||
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){} }
|
||||
}
|
||||
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){}
|
||||
});
|
||||
self.scrollBottom();
|
||||
@@ -1695,13 +1930,22 @@
|
||||
return read();
|
||||
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
|
||||
.finally(function(){
|
||||
self.streaming = false; self.liveReasoning='';
|
||||
self.streaming = false; self.liveReasoning=''; self._livePh = null;
|
||||
self.fetchConversationsRefresh();
|
||||
});
|
||||
},
|
||||
fetchConversationsRefresh(){
|
||||
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(){
|
||||
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
|
||||
@@ -1713,6 +1957,63 @@
|
||||
Alpine.data('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) ═══════════
|
||||
window.fdReadPageContext = function(){
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
||||
</style>
|
||||
<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>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
@@ -710,7 +710,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
// Inject CSRF token into HTMX headers
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
@@ -1912,7 +1912,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
(function(){
|
||||
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; } },
|
||||
|
||||
+186
-64
@@ -88,6 +88,13 @@
|
||||
.row-title:hover{text-decoration:underline;}
|
||||
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
|
||||
/* ── Tag chips (représentation + filtre) ── */
|
||||
.row-tags{display:flex;align-items:center;gap:3px;flex-shrink:0;overflow:hidden;max-width:45%;}
|
||||
.tag-chip{display:inline-flex;align-items:center;gap:3px;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;color:#fff;white-space:nowrap;border:none;cursor:pointer;font-family:inherit;}
|
||||
.tag-chip.active{outline:2px solid var(--text-primary);outline-offset:1px;}
|
||||
.lib-tag-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 16px 2px;}
|
||||
.lib-tag-filter .tf-label{font-size:11px;color:var(--text-dim);margin-right:2px;}
|
||||
|
||||
/* ── Shared direction sub-filter pils ── */
|
||||
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
|
||||
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
|
||||
@@ -271,6 +278,20 @@
|
||||
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter bar -->
|
||||
<div class="lib-tag-filter" x-show="workspaceTags && workspaceTags.length > 0" x-cloak>
|
||||
<span class="tf-label">Tags:</span>
|
||||
<template x-for="t in workspaceTags" :key="'ltf'+t.id">
|
||||
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
|
||||
:style="{ background: t.color }"
|
||||
@click="toggleTagFilter(t.name)">
|
||||
<span x-text="t.name"></span>
|
||||
<span style="font-size:10px;opacity:.7" x-text="'('+(t.count||0)+')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<button x-show="tagFilter" class="tag-chip" style="background:var(--bg-tertiary);color:var(--text-secondary);" @click="tagFilter='', _recomputeFlatItems()">clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar (toggleable) -->
|
||||
<div class="lib-search-bar" x-show="searchOpen" x-transition>
|
||||
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
|
||||
@@ -279,6 +300,10 @@
|
||||
<!-- ── Multi-select bar ── -->
|
||||
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
|
||||
<span class="sel-count" x-text="selectedCount + ' selected'"></span>
|
||||
<button @click="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<button @click="clearSelection()" title="Clear selection">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -329,6 +354,7 @@
|
||||
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
|
||||
<div class="lib-table-header">
|
||||
<div class="th lib-col-name">
|
||||
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Page name
|
||||
</div>
|
||||
@@ -358,47 +384,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row context menu ── -->
|
||||
<div class="more-menu" x-show="ctxMenuItem" @click.outside="ctxMenuItem=null" x-transition
|
||||
:style="'top:' + ctxMenuY + 'px;left:' + ctxMenuX + 'px;'">
|
||||
<template x-if="ctxMenuItem">
|
||||
<div>
|
||||
<div class="menu-item" @click="openItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Open
|
||||
</div>
|
||||
<div class="menu-item" @click="openPeek(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
Open in side peek
|
||||
</div>
|
||||
<div class="menu-item" @click="startRename(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
|
||||
Rename
|
||||
</div>
|
||||
<div class="menu-item" @click="toggleFavoriteItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
<span x-text="ctxMenuItem.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
|
||||
</div>
|
||||
<div class="menu-item" @click="duplicateItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
Duplicate
|
||||
</div>
|
||||
<div class="menu-item" @click="copyLink(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy link
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker([ctxMenuItem.id]); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
<div class="more-sep"></div>
|
||||
<div class="menu-item menu-danger" @click="deleteItem(ctxMenuItem); ctxMenuItem=null">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
Move to Trash
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- ── Move-to modal ── -->
|
||||
<div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition>
|
||||
@@ -411,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-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>
|
||||
</template>
|
||||
</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>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
@@ -470,7 +456,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
@@ -502,17 +488,16 @@ function libraryPage() {
|
||||
showAuthor: false,
|
||||
showSource: false,
|
||||
|
||||
// Tags
|
||||
workspaceTags: [],
|
||||
tagFilter: '',
|
||||
|
||||
// Sort
|
||||
sortBy: '',
|
||||
|
||||
// More menu
|
||||
moreOpen: false,
|
||||
|
||||
// Row context menu
|
||||
ctxMenuItem: null,
|
||||
ctxMenuX: 0,
|
||||
ctxMenuY: 0,
|
||||
|
||||
// Move-to modal
|
||||
moveModalOpen: false,
|
||||
moveIds: [],
|
||||
@@ -593,19 +578,24 @@ function libraryPage() {
|
||||
} else {
|
||||
html += '<span style="width:18px;flex-shrink:0;"></span>';
|
||||
}
|
||||
// Icon — folders get a folder icon
|
||||
// Icon — monochrome, comme local-workspace (emoji custom si défini)
|
||||
var isFolder = item.is_folder || item.has_children;
|
||||
if (isFolder) {
|
||||
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
|
||||
} else {
|
||||
html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</span>';
|
||||
}
|
||||
html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
|
||||
// Title or rename input
|
||||
if (isRenaming) {
|
||||
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
|
||||
} else {
|
||||
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
|
||||
}
|
||||
// Tags
|
||||
if (item.tags && item.tags.length) {
|
||||
html += '<span class="row-tags">';
|
||||
for (var ti = 0; ti < item.tags.length; ti++) {
|
||||
var tg = item.tags[ti];
|
||||
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
}
|
||||
// OPEN button (side peek) — hidden for folders
|
||||
if (!isFolder) {
|
||||
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
|
||||
@@ -627,12 +617,15 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(icon, fallback) {
|
||||
var name = icon || fallback || 'file';
|
||||
// Only allow known icon names, otherwise use fallback
|
||||
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 (valid.indexOf(name) === -1) name = fallback || 'file';
|
||||
return getSvgIcon(name, 14);
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(item.page_icon) + '</span>';
|
||||
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
|
||||
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
|
||||
var n = (item.title || '').toLowerCase();
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
|
||||
return getSvgIcon('file', 14);
|
||||
},
|
||||
|
||||
_recomputeFlatItems() {
|
||||
@@ -644,6 +637,12 @@ function libraryPage() {
|
||||
return (r.title || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
if (this.tagFilter) {
|
||||
var tf = this.tagFilter;
|
||||
result = result.filter(function(r) {
|
||||
return (r.tags || []).some(function(t) { return t.name === tf; });
|
||||
});
|
||||
}
|
||||
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
|
||||
this.flatItems.splice(0, this.flatItems.length, ...result);
|
||||
this.selectedCount = Object.keys(this.selected).length;
|
||||
@@ -706,9 +705,30 @@ function libraryPage() {
|
||||
evt.preventDefault();
|
||||
var item = this._findItem(id);
|
||||
if (!item) return;
|
||||
this.ctxMenuItem = item;
|
||||
this.ctxMenuX = Math.min(evt.clientX, window.innerWidth - 220);
|
||||
this.ctxMenuY = Math.min(evt.clientY, window.innerHeight - 320);
|
||||
if (!(window.Alpine && Alpine.store('fdCtx'))) return;
|
||||
/* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library
|
||||
complète l'union. Open folder/tags non prévus côté library :
|
||||
fdCtx les cache automatiquement via handlers absents. */
|
||||
var self = this;
|
||||
var handlers = {
|
||||
open: function (n) { self.openItem(n); },
|
||||
openTab: function (n) { self.openInNewTab(n); },
|
||||
peek: function (n) { self.openPeek(n); },
|
||||
rename: function (n) { self.startRename(n); },
|
||||
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyLink(n); },
|
||||
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) ──
|
||||
@@ -843,6 +863,7 @@ function libraryPage() {
|
||||
this.tab = t;
|
||||
}
|
||||
this._recomputeEmpty();
|
||||
this.loadWorkspaceTags();
|
||||
console.log('[Library] loading tab: ' + this.tab);
|
||||
this.loadTab();
|
||||
},
|
||||
@@ -852,6 +873,7 @@ function libraryPage() {
|
||||
if (t === 'repository' && !this.isGiteaActive) return;
|
||||
this.tab = t;
|
||||
this.shareDir = 'all';
|
||||
this.tagFilter = '';
|
||||
this.items = [];
|
||||
this.selected = {};
|
||||
this.selectedCount = 0;
|
||||
@@ -1073,6 +1095,106 @@ function libraryPage() {
|
||||
if (window.showToast) window.showToast('Link copied', 'success');
|
||||
},
|
||||
|
||||
openInNewTab(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.open('/pages/' + item.id, '_blank');
|
||||
},
|
||||
|
||||
removeItemFromRecents(item) {
|
||||
if (!item) return;
|
||||
var id = item.id;
|
||||
this.items = this.items.filter(function(it) { return it.id !== id; });
|
||||
this._recomputeFlatItems();
|
||||
if (window.showToast) window.showToast('Removed from Recents', 'success');
|
||||
},
|
||||
|
||||
async setItemIcon(item, icon) {
|
||||
if (!item || !item.id) return;
|
||||
icon = (icon || '').trim();
|
||||
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) {
|
||||
if (!item) return;
|
||||
var csrf = this._getCsrf();
|
||||
|
||||
+316
-235
@@ -59,6 +59,19 @@
|
||||
.ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.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) ── */
|
||||
.ws-table{width:100%;}
|
||||
.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;}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||
window._wsInitData = (function() {
|
||||
return {
|
||||
@@ -513,18 +526,13 @@ window._wsInitData = (function() {
|
||||
viewIcons: {tree:'',list:'',details:'',title:'',content:''},
|
||||
viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
|
||||
|
||||
// Context menu
|
||||
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'],
|
||||
// Context menu — state lives in the shared fdCtx Alpine store (_ctx_menu.html)
|
||||
|
||||
// Tags
|
||||
workspaceTags: [],
|
||||
tagFilter: '',
|
||||
tagInputId: null,
|
||||
tagsVersion: 0,
|
||||
|
||||
// Preview panel
|
||||
previewItem: null,
|
||||
@@ -534,6 +542,15 @@ window._wsInitData = (function() {
|
||||
selectedIds: {},
|
||||
selectedIdsCount: 0,
|
||||
lastSelectedId: null,
|
||||
allSelected: false,
|
||||
moreOpen: false,
|
||||
|
||||
// Move-to modal
|
||||
moveModalOpen: false,
|
||||
moveIds: [],
|
||||
moveSearchQuery: '',
|
||||
moveCandidates: [],
|
||||
_moveAllCandidates: [],
|
||||
|
||||
// Undo
|
||||
undoVisible: false,
|
||||
@@ -685,6 +702,7 @@ window._wsInitData = (function() {
|
||||
this.flatTree = ft;
|
||||
// filteredTableItems
|
||||
this._computeFilteredTableItems();
|
||||
this._syncSelection();
|
||||
},
|
||||
|
||||
// ── Pin/Favorite items ──
|
||||
@@ -702,7 +720,8 @@ window._wsInitData = (function() {
|
||||
|
||||
// ── Multi-select ──
|
||||
toggleSelect(node, ev) {
|
||||
if (ev.shiftKey && this.lastSelectedId) {
|
||||
var e = ev || {};
|
||||
if (e.shiftKey && this.lastSelectedId) {
|
||||
// Range select
|
||||
var allIds = this._collectVisibleIds(this.displayTree);
|
||||
var start = allIds.indexOf(this.lastSelectedId);
|
||||
@@ -711,18 +730,19 @@ window._wsInitData = (function() {
|
||||
var from = Math.min(start, end), to = Math.max(start, end);
|
||||
for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true;
|
||||
}
|
||||
} else if (ev.ctrlKey || ev.metaKey) {
|
||||
// Toggle single
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Toggle single, keep others
|
||||
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
|
||||
else this.selectedIds[node.id] = true;
|
||||
} else {
|
||||
// Replace selection
|
||||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||||
this.selectedIds[node.id] = true;
|
||||
// Notion-style: toggle this item, keep the rest of the selection
|
||||
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
|
||||
else this.selectedIds[node.id] = true;
|
||||
}
|
||||
this.lastSelectedId = node.id;
|
||||
// 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) {
|
||||
var ids = [];
|
||||
@@ -755,16 +775,19 @@ window._wsInitData = (function() {
|
||||
clearSelection() {
|
||||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||||
this.selectedIds = {};
|
||||
this.selectedIdsCount = 0;
|
||||
this.moreOpen = false;
|
||||
this._syncSelection();
|
||||
},
|
||||
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);
|
||||
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 ──
|
||||
@@ -841,7 +864,7 @@ window._wsInitData = (function() {
|
||||
selectForPreview(node) {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
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;
|
||||
var openBtn = document.getElementById('ws-preview-open-btn');
|
||||
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
||||
@@ -980,61 +1003,95 @@ window._wsInitData = (function() {
|
||||
// ── Content snippet for content view ──
|
||||
contentSnippetMap: {},
|
||||
|
||||
// ── Context menu ──
|
||||
// ── Context menu (menu unifié partagé via le store Alpine fdCtx) ──
|
||||
onContextMenu(ev) {
|
||||
var el = ev.target.closest('[data-ws-id]');
|
||||
var menu = document.querySelector('.more-menu');
|
||||
if (!el || !menu) { if (menu) menu.style.display = 'none'; return; }
|
||||
if (!el) return;
|
||||
var id = parseInt(el.getAttribute('data-ws-id'));
|
||||
if (!id) { menu.style.display = 'none'; return; }
|
||||
var node = this._findNodeById(this.displayTree, id);
|
||||
if (!node) { menu.style.display = 'none'; return; }
|
||||
// Ensure workspace tags are loaded
|
||||
if (!this.workspaceTags || this.workspaceTags.length === 0) {
|
||||
this.loadWorkspaceTags();
|
||||
}
|
||||
// Store in Alpine data for menu actions (ctxMenuOpenPage, etc.)
|
||||
this.ctxMenu = {visible:true, x:ev.clientX, y:ev.clientY, node:node, tags:[...(node.tags||[])]};
|
||||
this.ctxTagAdding = false;
|
||||
this.ctxTagExistingOpen = false;
|
||||
this.ctxNewTagColor = '#787774';
|
||||
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
|
||||
return !(node.tags||[]).some(function(nt) { return nt.id === t.id; });
|
||||
if (!id) return;
|
||||
var node = this._findNodeById(this.displayTree, id) || this._findNodeById(this.tree, id);
|
||||
if (!node) return;
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
if (!store) return;
|
||||
var self = this;
|
||||
if (!this.workspaceTags || this.workspaceTags.length === 0) this.loadWorkspaceTags();
|
||||
store.setAvail(this._ctxAvailTags(node));
|
||||
var handlers = {
|
||||
open: function (n) { self.openPage(n.id); },
|
||||
openTab: function (n) { window.open('/pages/' + n.id, '_blank'); },
|
||||
peek: function (n) { self.openSidePeek(n.id); },
|
||||
folder: function (n) { if (n.is_folder) self.navigateToFolder(n.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';
|
||||
menu.style.top = ev.clientY + 'px';
|
||||
// Reposition if offscreen
|
||||
var rect = menu.getBoundingClientRect();
|
||||
var x = ev.clientX, y = ev.clientY;
|
||||
if (x + rect.width > window.innerWidth - 4) x = window.innerWidth - rect.width - 4;
|
||||
if (y + rect.height > window.innerHeight - 4) y = window.innerHeight - rect.height - 4;
|
||||
if (x < 4) x = 4;
|
||||
if (y < 4) y = 4;
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
this.ctxMenu.x = x;
|
||||
this.ctxMenu.y = y;
|
||||
// Sync to _ctxMenuData for Alpine reactivity
|
||||
var c = window._ctxMenuData;
|
||||
c.ctxMenu = this.ctxMenu;
|
||||
c.ctxTagAdding = this.ctxTagAdding;
|
||||
c.ctxTagExistingOpen = this.ctxTagExistingOpen;
|
||||
c.ctxAvailTags = this.ctxAvailTags;
|
||||
c.ctxNewTagColor = this.ctxNewTagColor;
|
||||
// Also force Alpine update on the menu element
|
||||
var menuEl = document.querySelector('.more-menu');
|
||||
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
|
||||
var d = menuEl._x_dataStack[0];
|
||||
d.ctxMenu = this.ctxMenu;
|
||||
d.ctxTagAdding = this.ctxTagAdding;
|
||||
d.ctxTagExistingOpen = this.ctxTagExistingOpen;
|
||||
d.ctxAvailTags = [...this.ctxAvailTags];
|
||||
d.ctxNewTagColor = this.ctxNewTagColor;
|
||||
},
|
||||
|
||||
_ctxRefreshAvail() {
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
|
||||
},
|
||||
|
||||
copyItemLink(node) {
|
||||
if (!node) return;
|
||||
navigator.clipboard.writeText(window.location.origin + '/pages/' + node.id);
|
||||
if (window.showToast) window.showToast('Link copied', 'success');
|
||||
},
|
||||
|
||||
async toggleFavorite(node) {
|
||||
if (!node) return;
|
||||
try {
|
||||
var method = node.favorited ? 'DELETE' : 'POST';
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
|
||||
if (r.ok) {
|
||||
node.favorited = !node.favorited;
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
if (window.showToast) window.showToast(node.favorited ? 'Added to Favorites' : 'Removed from Favorites', 'success');
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async setItemIcon(node, icon) {
|
||||
if (!node || !node.id) return;
|
||||
icon = (icon || '').trim();
|
||||
if (!icon) return;
|
||||
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) ──
|
||||
_touchStartX: 0,
|
||||
_touchStartY: 0,
|
||||
@@ -1079,84 +1136,39 @@ window._wsInitData = (function() {
|
||||
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) {
|
||||
var node = this.ctxMenu.node;
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var node = store && store.node;
|
||||
if (!node) return;
|
||||
this.removeTag(node.id, tagId);
|
||||
this.ctxMenu.tags = this.ctxMenu.tags.filter(function(t) { return t.id !== tagId; });
|
||||
// Refresh available tags
|
||||
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;
|
||||
}
|
||||
node.tags = (node.tags || []).filter(function(t) { return t.id !== tagId; });
|
||||
this._ctxRefreshAvail();
|
||||
},
|
||||
|
||||
async ctxAddNewTag(tagName, color) {
|
||||
tagName = (tagName || '').trim();
|
||||
if (!tagName) return;
|
||||
var node = this.ctxMenu.node;
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var node = store && store.node;
|
||||
if (!node) return;
|
||||
color = color || window._ctxMenuData.ctxNewTagColor;
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: tagName.trim(), color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.ctxMenu.tags.push(d.tag);
|
||||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
// Refresh available tags
|
||||
var self = this;
|
||||
setTimeout(function() { self.loadWorkspaceTags().then(function() {
|
||||
self.ctxAvailTags = (self.workspaceTags || []).filter(function(t) {
|
||||
return !(self.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
|
||||
});
|
||||
// Sync to _ctxMenuData
|
||||
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;
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + node.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 = node.tags || [];
|
||||
if (!cur.some(function(t) { return t.id === d.tag.id; })) node.tags = cur.concat([d.tag]);
|
||||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
this._ctxRefreshAvail();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
_markTree(nodes, depth) {
|
||||
@@ -1177,7 +1189,7 @@ window._wsInitData = (function() {
|
||||
for (var i=0; i<children.length; i++) {
|
||||
var c = children[i];
|
||||
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 escId = c.id;
|
||||
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) {
|
||||
@@ -1392,6 +1406,8 @@ window._wsInitData = (function() {
|
||||
this._saveExpandedState();
|
||||
this.previewItem = null;
|
||||
this._recompute();
|
||||
var self = this;
|
||||
this.$nextTick(function() { self._syncCheckboxes(); });
|
||||
} catch(e) { console.error('Load folder error:', e); }
|
||||
},
|
||||
|
||||
@@ -1466,6 +1482,8 @@ window._wsInitData = (function() {
|
||||
|
||||
// ── Keyboard shortcuts ──
|
||||
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 (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
@@ -1725,7 +1743,8 @@ window._wsInitData = (function() {
|
||||
},
|
||||
|
||||
// ── 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>';
|
||||
// FlowDeck internal pages (no extension, blocks/markdown format)
|
||||
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
|
||||
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
this.tagInputId = null;
|
||||
@@ -2239,7 +2260,9 @@ window._wsInitData = (function() {
|
||||
if (r.ok) {
|
||||
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
@@ -2248,10 +2271,10 @@ window._wsInitData = (function() {
|
||||
for (var i=0; i<nodes.length; i++) {
|
||||
if (nodes[i].id === itemId) {
|
||||
if (action === 'add') {
|
||||
if (!nodes[i].tags) nodes[i].tags = [];
|
||||
// Avoid duplicates
|
||||
if (!nodes[i].tags.some(function(t) { return t.id === tag.id; })) {
|
||||
nodes[i].tags.push(tag);
|
||||
// Réassigne un nouveau tableau → déclenche la réactivité Alpine
|
||||
var cur = nodes[i].tags || [];
|
||||
if (!cur.some(function(t) { return t.id === tag.id; })) {
|
||||
nodes[i].tags = cur.concat([tag]);
|
||||
}
|
||||
} else if (action === 'remove') {
|
||||
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];
|
||||
}
|
||||
this.selectedIds = Object.assign({}, this.selectedIds);
|
||||
this._syncSelection();
|
||||
},
|
||||
|
||||
// ── Selection sync (keeps checkboxes & counters in sync) ──
|
||||
_syncSelection() {
|
||||
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) {}
|
||||
}
|
||||
_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);
|
||||
</script>
|
||||
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@click.self="ctxMenu.visible = false"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($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">
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions bar -->
|
||||
<!-- Bulk actions bar (Library-style) -->
|
||||
<div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
|
||||
<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="bulkDelete()" style="margin-left:auto;color:var(--danger);" title="Delete">
|
||||
<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 @click="clearSelection()" title="Clear selection">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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)">
|
||||
<!-- Parent folder ".." -->
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
<!-- Children -->
|
||||
<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>
|
||||
</li>
|
||||
</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) -->
|
||||
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
|
||||
<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>Path</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] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></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 class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -2687,7 +2802,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
</tr>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -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">
|
||||
<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 }">
|
||||
<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>
|
||||
<td class="col-name" x-text="node.name"></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">
|
||||
<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 }">
|
||||
<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">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></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()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<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 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>
|
||||
@@ -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>
|
||||
</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 -->
|
||||
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
@@ -2864,63 +2998,10 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
|
||||
</div><!-- /ws-main -->
|
||||
|
||||
<!-- Context menu (Library-style) -->
|
||||
<div class="more-menu" x-data="_ctxMenuData" style="display:none"
|
||||
@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>
|
||||
<!-- Context menu (Library-style) — MENU UNIQUE partagé (workspace ⇄ library) -->
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
|
||||
@@ -137,7 +137,16 @@
|
||||
</header>
|
||||
|
||||
<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 %}
|
||||
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<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>
|
||||
: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;}
|
||||
|
||||
@@ -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
Binary file not shown.
+19
-5
@@ -1,10 +1,10 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
# Parallel execution via pytest-xdist (v5.2.0). Override with `-n 0` to run
|
||||
# sequentially: `pytest -n 0`.
|
||||
# Note: some tests use shared temp directories — run sequentially for stability.
|
||||
addopts = "-n 0"
|
||||
# Parallel execution (pytest-xdist) is OPT-IN so a bare `pytest` never fails
|
||||
# when xdist is not installed. Run locally with `pytest -n auto`; CI passes
|
||||
# `-n auto` explicitly after installing requirements-dev.txt.
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
@@ -18,4 +18,18 @@ select = ["E", "F", "I", "UP", "B", "W"]
|
||||
ignore = ["E501", "UP035"]
|
||||
|
||||
[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
@@ -22,7 +22,7 @@
|
||||
xhr.open('POST', '/api/frontend-error', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.send(JSON.stringify(error));
|
||||
} catch(e) { /* fail silently */ }
|
||||
} catch { /* fail silently */ }
|
||||
}
|
||||
|
||||
window.addEventListener('error', function(e) {
|
||||
|
||||
+22
-7
@@ -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 tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -15,7 +21,7 @@ def client():
|
||||
|
||||
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["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
@@ -23,9 +29,21 @@ def client():
|
||||
os.environ["BACKUP_DIR"] = backup_dir
|
||||
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
|
||||
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.main import app
|
||||
@@ -47,6 +65,3 @@ def client():
|
||||
Path(backup_dir).rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -207,6 +207,78 @@ def test_tool_workspace_document_crud_and_search(client):
|
||||
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):
|
||||
from app.db import get_conn
|
||||
from app.services.agent_engine import undo_action
|
||||
|
||||
+17
-2
@@ -19,10 +19,25 @@ def client():
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
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.main import app
|
||||
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)
|
||||
|
||||
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."""
|
||||
from app.db import get_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"]
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('NavWS', ?)", (uid,))
|
||||
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},
|
||||
cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
cid = r.json()["id"]
|
||||
r.json()["id"]
|
||||
|
||||
r = client.get(f"/api/pages/{pid}/comments", cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -159,7 +159,7 @@ def test_automation_scope_collection_filter(client):
|
||||
}).json()["id"]
|
||||
|
||||
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
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -17,6 +17,11 @@ def client():
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
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.main import app
|
||||
init_db()
|
||||
|
||||
+10
-5
@@ -194,7 +194,8 @@ def test_ws_presence_join_leave(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
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")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
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"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
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")
|
||||
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.send_json({"t": "sel", "block": "a", "offset": 1})
|
||||
m = wb.receive_json()
|
||||
@@ -229,10 +232,12 @@ def test_ws_title_broadcast(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
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")
|
||||
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.send_json({"t": "title", "title": "Nouveau titre"})
|
||||
m = wb.receive_json()
|
||||
|
||||
@@ -20,6 +20,11 @@ def client():
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
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.main import app
|
||||
init_db()
|
||||
|
||||
+203
-9
@@ -148,16 +148,64 @@ def test_onboarding_workspace_and_project(client):
|
||||
# ═══════════════ Backups ═══════════════
|
||||
|
||||
def test_backup_snapshot_and_pruning(client):
|
||||
"""Skipped: flaky due to test isolation issues with global config state.
|
||||
Passes when run in isolation.
|
||||
"""
|
||||
pytest.skip("Flaky: backup_dir cleanup interference between tests")
|
||||
"""backup_db() snapshots the SQLite file and prune keeps the newest N."""
|
||||
import datetime
|
||||
|
||||
def test_backup_admin_api_requires_admin(client):
|
||||
"""Skipped: flaky due to test isolation issues with global config state.
|
||||
Passes when run in isolation.
|
||||
"""
|
||||
pytest.skip("Flaky: admin API test interference between tests")
|
||||
from app.services import backup as backup_svc
|
||||
|
||||
base = datetime.datetime(2026, 1, 1, 0, 0, 0)
|
||||
first = backup_svc.backup_db(now=base)
|
||||
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 ═══════════════
|
||||
@@ -266,6 +314,152 @@ def test_projects_sync_with_mock_client(client):
|
||||
|
||||
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 ═══════════════
|
||||
|
||||
def test_permission_manager_roles(client):
|
||||
|
||||
@@ -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}§ion=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
|
||||
Reference in New Issue
Block a user