From f4ad4f5b6d69cf5cf95a42cba190932d8350efb3 Mon Sep 17 00:00:00 2001 From: bruno Date: Sun, 12 Jul 2026 10:52:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20syst=C3=A8me=20de=20tags=20+=20vue=20ta?= =?UTF-8?q?ble=20enrichie=20pour=20recherche/filtre=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: tables tags + page_tags avec index - API: CRUD tags (GET/POST/DELETE items/{id}/tags, GET /tags, GET /tags/search) - Tree endpoint enrichi: size, dates, author, tags par nœud - Table enrichie: colonnes Name, Path, Size, Modified, Type, Author, Tags - Tag chips avec compteurs + filtrage par clic - Ajout/retrait tags inline dans la table - Filtre Folders + auto-switch table quand recherche active --- CHANGELOG.md | 12 ++ app/db.py | 21 +- app/main.py | 2 +- app/routers/dashboard.py | 186 +++++++++++++++++- app/templates/local_workspace.html | 301 ++++++++++++++++++++++++++++- 5 files changed, 509 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f038246..745a9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog — FlowDeck +## v2.2.0 (2026-07-12) — Tags & Recherche Avancée + +### Added +- **Tags**: système complet de tags avec tables `tags` + `page_tags`, CRUD API +- **API Tags**: `GET/POST/DELETE /api/local-workspace/items/{id}/tags`, `GET /api/local-workspace/tags`, `GET /api/local-workspace/tags/search?tags=...` +- **Vue Table enrichie**: colonnes Name, Path, Size, Modified, Type, Author, Tags +- **Filtrage par tags**: barre de chips avec compteurs, clic pour filtrer +- **Ajout/retrait tags inline**: bouton + dans la table, chips avec × pour retirer +- **Tree endpoint enrichi**: chaque nœud retourne `size`, `size_display`, `created_at`, `updated_at`, `author`, `tags` +- **Filtre "Dossiers"**: nouveau chip 📁 Folders dans la barre de filtres +- **Auto-switch table**: quand recherche/filtre actif, passe en vue table automatiquement + ## v2.1.1 (2026-07-12) — Fix Arborescence Workspace ### Fixed diff --git a/app/db.py b/app/db.py index 0cebc7f..21cbf0a 100644 --- a/app/db.py +++ b/app/db.py @@ -123,8 +123,8 @@ def init_db(): project_owner TEXT NOT NULL, project_name TEXT NOT NULL, name TEXT NOT NULL, - prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text - options_json TEXT DEFAULT '[]', -- for select/multi_select + prop_type TEXT NOT NULL DEFAULT 'select', + options_json TEXT DEFAULT '[]', position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(project_owner, project_name, name) @@ -293,6 +293,23 @@ def init_db(): content_json TEXT DEFAULT '[]', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + -- v2.2.0: Tags system + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT DEFAULT '#787774', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS page_tags ( + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (page_id, tag_id) + ); + CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id); + CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id); """) # Migration: add parent_id if missing (v1.0.0+) try: diff --git a/app/main.py b/app/main.py index 82d32c8..9e81d57 100644 --- a/app/main.py +++ b/app/main.py @@ -39,7 +39,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="2.1.1", + version="2.2.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 6fca9a5..12065d7 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -581,21 +581,60 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> 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 FROM pages " - "WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC", + "SELECT id, title, parent_section, content_format, content, " + "created_at, updated_at FROM pages " + "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL " + "ORDER BY created_at DESC", (ws_id,), ).fetchall() else: rows = conn.execute( - "SELECT id, title, parent_section, content_format FROM pages " - "WHERE parent_id=? AND workspace_id=? ORDER BY created_at DESC", + "SELECT id, title, parent_section, content_format, content, " + "created_at, updated_at FROM pages " + "WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL " + "ORDER BY created_at DESC", (parent_id, ws_id), ).fetchall() + # Get workspace owner name for author display + ws_owner = conn.execute( + "SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?", + (ws_id,), + ).fetchone() + author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—" + + # Collect all page IDs to fetch tags in one query + all_ids = [r["id"] for r in rows] + tags_map = {} + if all_ids: + placeholders = ",".join("?" for _ in all_ids) + tag_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})", + all_ids, + ).fetchall() + for tr in tag_rows: + tags_map.setdefault(tr["page_id"], []).append({ + "id": tr["id"], "name": tr["name"], "color": tr["color"], + }) + tree = [] for r in rows: is_folder = r["parent_section"] == "Workspace" children = _build_tree_children(conn, r["id"], ws_id) + + # Compute size + size = 0 + if r["content_format"] == "file": + import json as _json + try: + meta = _json.loads(r["content"]) + size = meta.get("size", 0) + except Exception: + size = len(r["content"] or "") + else: + size = len(r["content"] or "") + tree.append({ "id": r["id"], "name": r["title"] or "Untitled", @@ -605,10 +644,27 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: "children": children, "has_children": len(children) > 0, "child_count": len(children), + "size": size, + "size_display": _format_size(size), + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "author": author, + "tags": tags_map.get(r["id"], []), }) return tree +def _format_size(size_bytes: int) -> str: + """Human-readable file size.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + def _build_breadcrumb(conn, folder_id: int) -> list: """Build breadcrumb trail from root to folder_id.""" breadcrumb = [] @@ -1022,3 +1078,125 @@ async def select_workspace(request: Request, ws_id: int): response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/") return response + + +# ═══════════ Tags API ═══════════ + +@router.get("/api/local-workspace/tags") +async def list_tags(request: Request): + """List all tags used in the active workspace.""" + ws = _get_active_workspace(request) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"tags": []} + import json + with get_conn() as conn: + rows = conn.execute( + "SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count " + "FROM tags t " + "JOIN page_tags pt ON pt.tag_id = t.id " + "JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " + "GROUP BY t.id ORDER BY t.name", + (ws_id,), + ).fetchall() + return {"tags": [dict(r) for r in rows]} + + +@router.get("/api/local-workspace/items/{item_id:int}/tags") +async def get_item_tags(item_id: int): + """Get tags for a specific item.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.color FROM tags t " + "JOIN page_tags pt ON pt.tag_id = t.id " + "WHERE pt.page_id = ? ORDER BY t.name", + (item_id,), + ).fetchall() + return {"tags": [dict(r) for r in rows]} + + +@router.post("/api/local-workspace/items/{item_id:int}/tags") +async def add_item_tag(request: Request, item_id: int): + """Add a tag to an item (creates tag if new).""" + import json + body = await request.json() + tag_name = body.get("name", "").strip().lower() + if not tag_name: + return {"error": "Tag name required"}, 400 + + with get_conn() as conn: + # Get or create tag + tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ?", (tag_name,)).fetchone() + if not tag: + cursor = conn.execute( + "INSERT INTO tags (name) VALUES (?)", (tag_name,) + ) + conn.commit() + tag_id = cursor.lastrowid + tag = {"id": tag_id, "name": tag_name, "color": "#787774"} + else: + tag_id = tag["id"] + + # Link tag to page (ignore duplicate) + try: + conn.execute( + "INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", + (item_id, tag_id), + ) + conn.commit() + except Exception: + pass + + return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} + + +@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") +async def remove_item_tag(item_id: int, tag_id: int): + """Remove a tag from an item.""" + with get_conn() as conn: + conn.execute( + "DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?", + (item_id, tag_id), + ) + conn.commit() + return {"status": "ok"} + + +@router.get("/api/local-workspace/tags/search") +async def search_by_tags(request: Request, tags: str = ""): + """Search items by tags (comma-separated).""" + ws = _get_active_workspace(request) + ws_id = ws["id"] if ws else None + if not ws_id: + return {"items": []} + import json + tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()] + if not tag_names: + return {"items": []} + with get_conn() as conn: + placeholders = ",".join("?" for _ in tag_names) + rows = conn.execute( + f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, " + f"p.content, p.created_at, p.updated_at " + f"FROM pages p " + f"JOIN page_tags pt ON pt.page_id = p.id " + f"JOIN tags t ON t.id = pt.tag_id " + f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL " + f"ORDER BY p.updated_at DESC", + tag_names + [ws_id], + ).fetchall() + items = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + size = len(r["content"] or "") + items.append({ + "id": r["id"], + "name": r["title"] or "Untitled", + "is_folder": is_folder, + "content_format": r["content_format"], + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "size": size, + "size_display": _format_size(size), + }) + return {"items": items} diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index cc1c13f..f50c10b 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -189,6 +189,38 @@ .file-preview{position:fixed;z-index:300;background:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:400px;min-width:200px;pointer-events:auto;} .file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary,#888);border-bottom:1px solid var(--border,#333);} .file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text,#ddd);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:#141414;} + +/* Tag chips */ +.tag-chip{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:rgba(120,119,116,.15);border-radius:10px;font-size:11px;color:var(--text-secondary);white-space:nowrap;margin:1px 2px;transition:all 150ms;} +.tag-chip:hover{background:rgba(120,119,116,.25);} +.tag-chip.rm-btn{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;border-radius:50%;} +.tag-chip.rm-btn:hover{color:var(--danger);background:rgba(255,80,80,.15);} +.tag-chip.active{background:var(--accent);color:#fff;} +.tag-add-btn{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:none;border:1px dashed var(--border);border-radius:10px;font-size:11px;color:var(--text-tertiary);cursor:pointer;white-space:nowrap;transition:all 150ms;} +.tag-add-btn:hover{border-color:var(--accent);color:var(--accent);} +.tag-input{background:var(--bg);border:1px solid var(--accent);border-radius:8px;color:var(--text);font-size:11px;padding:2px 6px;outline:none;min-width:80px;} +.tag-filter-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px;padding:4px 0;} + +/* Enhanced table */ +.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px;} +.ws-table-enhanced th{text-align:left;padding:10px 12px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--bg);z-index:1;} +.ws-table-enhanced td{padding:8px 12px;border-bottom:1px solid var(--border);vertical-align:middle;} +.ws-table-enhanced tr:hover td{background:var(--bg-hover);} +.ws-table-enhanced .col-name{font-weight:500;cursor:pointer;min-width:150px;} +.ws-table-enhanced .col-name:hover{color:var(--accent);} +.ws-table-enhanced .col-path{color:var(--text-tertiary);font-size:12px;font-family:'Fira Code',monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.ws-table-enhanced .col-size{color:var(--text-secondary);font-size:12px;white-space:nowrap;} +.ws-table-enhanced .col-date{color:var(--text-tertiary);font-size:12px;white-space:nowrap;} +.ws-table-enhanced .col-type{font-size:12px;white-space:nowrap;} +.ws-table-enhanced .col-author{font-size:12px;color:var(--text-secondary);white-space:nowrap;} +.ws-table-enhanced .col-tags{min-width:120px;} +.ws-table-enhanced .table-icon{font-size:16px;margin-right:6px;vertical-align:-2px;} + +/* Table compact mode (inside filter/search) */ +.ws-table-compact{width:100%;border-collapse:collapse;font-size:13px;} +.ws-table-compact th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;} +.ws-table-compact td{padding:5px 10px;border-bottom:1px solid var(--border);} +.ws-table-compact tr:hover td{background:var(--bg-hover);}
+ @@ -376,8 +409,75 @@ - - + +
+ Tags: + + +
+ + +
+ + + + + + + + + + + + + + +
NamePathSizeModifiedTypeAuthorTags
+ + + @@ -391,7 +491,7 @@ - + @@ -503,6 +603,11 @@ document.addEventListener('alpine:init', function() { sortBy: '', viewMode: 'tree', + // Tags + workspaceTags: [], + tagFilter: '', + tagInputId: null, + // Multi-select selectedIds: {}, selectedIdsCount: 0, @@ -580,6 +685,8 @@ document.addEventListener('alpine:init', function() { var self = this; window.addEventListener('online', function() { self.online = true; }); window.addEventListener('offline', function() { self.online = false; }); + // Load workspace tags + this.loadWorkspaceTags(); } catch(e) { console.error('Tree load error:', e); this.loaded = true; } }, @@ -663,6 +770,66 @@ document.addEventListener('alpine:init', function() { } }, + // ── Filtered table items (for enhanced filter/search table) ── + get filteredTableItems() { + var self = this; + var q = (this.searchQuery || '').toLowerCase().trim(); + var ft = this.filterType || ''; + var tf = this.tagFilter || ''; + // If no filter active, return empty (show regular views) + if (!q && !ft && !tf) return []; + // Flatten entire tree + var all = []; + this._flattenTree(this.tree, all); + // Build path for each item + for (var i=0; i 0) { + var r = this._findInTree(n.children, targetId, currentPath.concat([n.name])); + if (r) return r; + } + } + return null; + }, + _markTree(nodes, depth) { for (var i=0; i
NameTypeSize