diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 57c1e04..0000000 --- a/.eslintrc.json +++ /dev/null @@ -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/**"] -} \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 09f4fea..f5d75f9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7311627..aa3175c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 4c66e8f..1cd06f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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* diff --git a/VERSION b/VERSION index 9266671..32447ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.9.1 \ No newline at end of file +5.11.1 diff --git a/app/auth/__init__.py b/app/auth/__init__.py index f46c919..e46aff3 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -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"] diff --git a/app/db.py b/app/db.py index a0fff76..bc3d475 100644 --- a/app/db.py +++ b/app/db.py @@ -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(): diff --git a/app/main.py b/app/main.py index 552772c..60c74eb 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py index aa51a09..f3c8d80 100644 --- a/app/middleware/__init__.py +++ b/app/middleware/__init__.py @@ -1,2 +1,4 @@ """FlowDeck — Custom middleware.""" from app.middleware.csrf import CSRFMiddleware + +__all__ = ["CSRFMiddleware"] diff --git a/app/migrations.py b/app/migrations.py index 91c4fcb..21a934e 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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, diff --git a/app/routers/agent.py b/app/routers/agent.py index 148f5cb..9560198 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -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"} diff --git a/app/routers/api.py b/app/routers/api.py index 43b723e..1d5cdaf 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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: diff --git a/app/routers/automations.py b/app/routers/automations.py index a3597cf..23d5dd2 100644 --- a/app/routers/automations.py +++ b/app/routers/automations.py @@ -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") diff --git a/app/routers/board.py b/app/routers/board.py index d3db1da..5021af3 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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 diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py index 681b2e4..571b01e 100644 --- a/app/routers/collaboration.py +++ b/app/routers/collaboration.py @@ -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: diff --git a/app/routers/collections.py b/app/routers/collections.py index 8d4082c..89d07a3 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -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 ' (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("→") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 0e77477..0adf471 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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}

404

""", 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'
' - f'{alt}' + f'{alt}' f'
' ) + elif t == "video": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + elif t == "audio": + src = b.get("src", "") + if src: + html_parts.append( + f'' + ) + 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'' if img else "" + ) + desc_html = f'
{desc}
' if desc else "" + site_html = f'
{site}
' if site else "" + html_parts.append( + f'' + f'
' + f'
{title}
' + f'{desc_html}{site_html}
{img_html}
' + ) + elif t == "embed": + url = b.get("src", "") + emb = b.get("embed_type") or "" + if emb in ("inline_dbs", "collection"): + html_parts.append('
[Embedded content]
') + elif emb == "download": + html_parts.append( + f'⬇ {b.get("file_name") or "Download"}' + ) + elif emb == "pdf" and url: + html_parts.append( + f'' + ) + 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'
' + ) else: html_parts.append(f'

{c}

') 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'. """ diff --git a/app/routers/export.py b/app/routers/export.py index 71330f5..dd23ee8 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -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) diff --git a/app/routers/gitea.py b/app/routers/gitea.py index 32b5e01..d817cba 100644 --- a/app/routers/gitea.py +++ b/app/routers/gitea.py @@ -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() diff --git a/app/routers/library.py b/app/routers/library.py index 1be596c..91bf6cf 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -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" diff --git a/app/routers/my_tasks.py b/app/routers/my_tasks.py index c67ff3b..1e69986 100644 --- a/app/routers/my_tasks.py +++ b/app/routers/my_tasks.py @@ -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() diff --git a/app/routers/notifications.py b/app/routers/notifications.py index 38be1c8..7e18c94 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -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: diff --git a/app/routers/sharing.py b/app/routers/sharing.py index 8257c51..edb5c10 100644 --- a/app/routers/sharing.py +++ b/app/routers/sharing.py @@ -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( diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py index 65fb473..5058aae 100644 --- a/app/routers/sidebar_config.py +++ b/app/routers/sidebar_config.py @@ -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): diff --git a/app/routers/webhooks.py b/app/routers/webhooks.py index 476a15f..34c560a 100644 --- a/app/routers/webhooks.py +++ b/app/routers/webhooks.py @@ -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}") diff --git a/app/routers/workspace.py b/app/routers/workspace.py index 42d19e8..e01a778 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -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"} diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index 739130f..8622d37 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -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() diff --git a/app/services/embeds.py b/app/services/embeds.py new file mode 100644 index 0000000..64cc808 --- /dev/null +++ b/app/services/embeds.py @@ -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'' + ) diff --git a/app/services/export.py b/app/services/export.py index 66bdd31..c22f96d 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -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"![{alt}]({src})") + elif t == "video": + out.append(f"[Video]({b.get('src') or ''})") + elif t == "audio": + out.append(f"[Audio]({b.get('src') or ''})") + elif t == "bookmark": + url = b.get("url") or b.get("src") or "" + title = (b.get("title") or "").strip() + out.append(f"[{title or url}]({url})" if title else url) + elif t == "embed": + url = b.get("src") or "" + if b.get("embed_type") in ("pdf", "download", None, ""): + out.append(f"[{url}]({url})" if url else "[embed]") + else: + out.append(f"[{url}]({url})" if url else "[embed]") elif t == "table": out.append(_table_to_markdown(b)) 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'
{alt}
{alt}
') + parts.append(f'
{alt}
{alt}
') + elif t == "video": + src = b.get("src") or "" + if src: + parts.append(f'') + elif t == "audio": + src = b.get("src") or "" + if src: + parts.append(f'') + 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'' if img else "" + desc_html = f'
{desc}
' if desc else "" + site_html = f'
{site}
' if site else "" + parts.append( + f'' + f'
' + f'
{title}
' + f'{desc_html}{site_html}
{img_html}
' + ) + elif t == "embed": + url = b.get("src") or "" + emb = (b.get("embed_type") or "") + if emb in ("inline_dbs", "collection"): + parts.append('
[Embedded content]
') + elif emb == "download": + parts.append(f'⬇ {_text(b.get("file_name") or "Download")}') + elif emb == "pdf" and url: + parts.append(f'') + 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'
' + ) elif t == "table": parts.append(_table_to_html(b)) else: diff --git a/app/services/notifications.py b/app/services/notifications.py index 89b0d22..31aee18 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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: diff --git a/app/services/og_fetcher.py b/app/services/og_fetcher.py new file mode 100644 index 0000000..4cd68d3 --- /dev/null +++ b/app/services/og_fetcher.py @@ -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']*?(?: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 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"]*>(.*?)", 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"]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I) + if not fm: + fm = re.search(r"]+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, + } diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index ee54923..6d9ad2b 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -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=?", diff --git a/app/services/trash.py b/app/services/trash.py new file mode 100644 index 0000000..c5c37b5 --- /dev/null +++ b/app/services/trash.py @@ -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) diff --git a/app/templates/_ctx_menu.html b/app/templates/_ctx_menu.html new file mode 100644 index 0000000..d66cfcd --- /dev/null +++ b/app/templates/_ctx_menu.html @@ -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 + ############################################################################} + + + + +
+ + {# ── Open ── #} + + + {# ── Open in new tab ── #} + + + {# ── Open in side peek ── #} + + + {# ── Open folder (workspace, dossiers) ── #} + + +
+ + {# ── Rename ── #} + + + {# ── Edit icon ── #} + +
+
+ +
+
+ + +
+
+ + {# ── Duplicate ── #} + + + {# ── Copy link ── #} + + + {# ── Move to ── #} + + + {# ── Favorites ── #} + + + {# ── Remove from Recents ── #} + + + {# ── Tags (workspace) ── #} +
+
+ + + + +
+ +
+ +
+
+ +
+
+ + +
+
+
+ + {# ── Delete (danger) ── #} +
+ +
diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 600d2d3..acad499 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -1,4 +1,8 @@ -