diff --git a/ROADMAP.md b/ROADMAP.md index bd94607..2c39f29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -543,16 +543,70 @@ Détails livrés : - **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé - **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK -### v5.6.0 — Import de données (étendu) -> **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types. +### v5.6.0 — Import de données (étendu) — Plan par phases +> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés +> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base +> (Markdown/CSV/Notion, déjà dans v5.4.0). **Priorité n°1 de la roadmap.** +> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep, +> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket, +> Readwise, Shaarli, `.ics`, OPML, Standard Notes. +> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées). -- [ ] **Import CSV typé** — inférence automatique des types de propriétés (texte, nombre, date, select) -- [ ] **Import Confluence / Evernote / Asana / Trello** — via leurs formats d'export (HTML/JSON) +#### Phase 0 — Socle d'import unifié (prérequis) +> Toutes les sources partagent le même pipeline ; à livrer avant les imports. +- [ ] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats) +- [ ] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import +- [ ] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs +- [ ] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom) +- [ ] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression +- [ ] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips) +- [ ] **Idempotence / déduplication** + reprise sur erreur partielle +- [ ] **Tests** — harnais d'import + fixtures par source +- [ ] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants + +#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort +- [ ] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)* +- [ ] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images +- [ ] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal +- [ ] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown +- [ ] **Google Keep** — Takeout JSON + HTML +- [ ] **OneNote** — export HTML/PDF, best-effort (fidélité limitée) + +#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen +- [ ] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options +- [ ] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections +- [ ] **Google Sheets** — export CSV/XLSX +- [ ] **JSON générique** — mapping configurable (JSONPath → propriétés) + +#### Phase 3 — Documents & bureautique — effort moyen +- [ ] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images +- [ ] **Google Docs** — Takeout `.docx`/HTML +- [ ] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs +- [ ] **PDF** — extraction texte + images (fidélité limitée) + +#### Phase 4 — Signets, dev & divers +- [ ] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif) +- [ ] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0) +- [ ] **Pocket** (CSV/HTML) +- [ ] **Readwise** (highlights CSV/Markdown) +- [ ] **Shaarli** (API/export JSON) → bookmark cards +- [ ] **Calendrier `.ics`** (Google/Outlook/Apple) +- [ ] **OPML** (flux/outlines) +- [ ] **Standard Notes / autres** + +#### Priorisation +| Ordre | Phase | Effort | Impact | +|---|---|---|---| +| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis | +| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) | +| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) | +| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) | +| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) | ### v5.7.0 — Database Avancée (Pt. 2) > **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. -- [ ] **Types propriété manquants** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` +- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI - [ ] **Groupes de propriétés** — sections pliables dans le header de DB - [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide) - [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement) @@ -647,6 +701,8 @@ Détails livrés : 5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests) 6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion) 7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés) +8. **v5.6.0 → Import de données (5 phases)** ⬅ **priorité n°1** — Phase 0 socle unifié → Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) → Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) → Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) → Phase 3 documents (Word, Google Docs, HTML, PDF) +9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro) --- ## Résumé des phases @@ -665,4 +721,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, Calendrier, AI duplicate) lightbox…) -*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0* +*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; **plan d'import v5.6.0 détaillé en 5 phases** (socle unifié, notes/Markdown, données/tableaux, documents, signets/dev/divers) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0* diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index e3c095b..0211eb7 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -1021,6 +1021,92 @@ async def get_page_content(page_id: int): return {"content": row["content"] or "", "format": fmt} +def _file_page_disk_path(page: dict): + """Resolve the on-disk file behind a ``content_format == 'file'`` page. + + Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None + when the row is not a file page, references a non-textual/missing file, or + the path escapes the data root (path-traversal guard). + """ + if (page.get("content_format") or "") != "file": + return None + import json as _json + try: + meta = _json.loads(page.get("content", "{}")) + except (_json.JSONDecodeError, TypeError): + meta = {} + if not isinstance(meta, dict): + return None + rel = (meta.get("file_path") or "").replace("\\", "/").strip() + if not rel or not rel.startswith("uploads/"): + return None + parts = rel.split("/") + if ".." in parts or "." in parts: + return None + import os as _os + from pathlib import Path + root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve() + full = (root / rel).resolve() + try: + full.relative_to(root) + except ValueError: + return None + if not full.exists() or not full.is_file(): + return None + filename = parts[-1] or page.get("title", "file") + mime = meta.get("mime_type") or "application/octet-stream" + size = meta.get("size") or 0 + return (full, filename, mime, size) + + +@router.get("/api/pages/{page_id}/download") +async def download_page_file(page_id: int): + """Download the original uploaded file of a ``file`` page (attachment).""" + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, content, content_format FROM pages " + "WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + resolved = _file_page_disk_path(dict(row)) + if not resolved: + return JSONResponse({"error": "No downloadable file"}, status_code=404) + full, filename, mime, _size = resolved + from fastapi.responses import FileResponse + return FileResponse( + str(full), media_type=mime or "application/octet-stream", + filename=filename, content_disposition_type="attachment", + ) + + +@router.get("/api/pages/{page_id}/file-content") +async def page_file_content(page_id: int): + """Return the textual content of a ``file`` page (for copy to clipboard). + + Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is + only meaningful for plain-text / code / markdown files. + """ + from app.services.export import _file_text + with get_conn() as conn: + row = conn.execute( + "SELECT id, title, content, content_format FROM pages " + "WHERE id=? AND deleted_at IS NULL", + (page_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "Not found"}, status_code=404) + page = dict(row) + text = _file_text(page) + if text is None: + return JSONResponse( + {"ok": False, "error": "Not a textual file", "name": page.get("title", "")}, + status_code=415, + ) + return {"ok": True, "name": page.get("title") or "File", "content": text} + + @router.get("/api/local-workspace/breadcrumb") async def local_workspace_breadcrumb(request: Request, folder: int): """Return breadcrumb trail for a folder.""" diff --git a/app/templates/_ctx_menu.html b/app/templates/_ctx_menu.html index d66cfcd..ac8ba1f 100644 --- a/app/templates/_ctx_menu.html +++ b/app/templates/_ctx_menu.html @@ -12,8 +12,8 @@ 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, openTab, peek, folder, rename, setIcon, duplicate, link, download, + copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove ############################################################################}