From 0112a5b5d88ea700693aceac1410987cbf840f72 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 8 Sep 2026 11:52:29 -0400 Subject: [PATCH] =?UTF-8?q?v5.5.0=20Partage=20v2=20:=20fen=C3=AAtre=20Shar?= =?UTF-8?q?e=20recharg=C3=A9e,=20sidebar=20=C2=AB=20Par=20moi=20=C2=BB/?= =?UTF-8?q?=C2=AB=20Avec=20moi=20=C2=BB,=20Library=20dir=3Dmade/received,?= =?UTF-8?q?=20badge=20=F0=9F=91=A5=20+=20indicateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correctif : la fenêtre Share rechargait ses partages à chaque ouverture (before: liste vide après refresh) - Sidebar « Shared » scindée en sous-groupes « Par moi » (partages nominatifs + liens) et « Avec moi » (partages reçus), sans doublons - Bibliothèque : filtre « Tous / Par moi / Avec moi » sur l'onglet Shared ; /api/library/shared?dir=made|received|all + share_dir par élément - Document : bouton barre affiche « 👥 Shared ▾ » quand la page est partagée (membre, lien ou publiée), recalculé à la volée - Workspace local : emoji 👥 juste avant le nom des fichiers partagés (is_shared exposé par /api/local-workspace/tree) - Tests +6 (tests/test_sharing.py) : direction made/received/all, fallback dir invalide, rendu pageIsShared ; suite 325 verte (+3 PDF pré-existants) --- CHANGELOG.md | 27 +++++++ ROADMAP.md | 2 +- VERSION | 2 +- app/main.py | 4 +- app/routers/board.py | 54 ++++++++++---- app/routers/dashboard.py | 8 +- app/routers/library.py | 40 +++++++++- app/templates/_page_editor_scripts.html | 11 ++- app/templates/base.html | 30 ++++++-- app/templates/library.html | 26 ++++++- app/templates/local_workspace.html | 2 + app/templates/page_editor.html | 2 +- app/templates/page_editor_collection.html | 2 +- static/css/app.css | 21 ++++++ tests/test_sharing.py | 91 ++++++++++++++++++++++- 15 files changed, 286 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d5649..baa450e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog - FlowDeck +## v5.5.0 (2026-09-08) — Partage : réorganisation & indicateurs + +> Vue complète du partage : sidebar « Shared » scindée en « Par moi » / +> « Avec moi », onglet Shared de la bibliothèque filtrable, fenêtre Share +> rechargée à l'ouverture (bug de refresh), et emoji 👥 sur les fichiers +> partagés du workspace local. + +- **Correctif fenêtre Share** — ouverture de la boîte de dialogue + (`toggleShareOpen`) au lieu de laisser la liste vide après un refresh ; + les partages sont rechargés (`loadShares`) à chaque ouverture. +- **Sidebar Shared** — la section affiche deux sous-groupes : « Par moi » + (partages nominatifs créés + pages en « lien ») et « Avec moi » (partages + reçus), chacun avec son propre état vide (les pages déjà dans « Par moi » + sont exclues de « Avec moi » pour éviter les doublons). +- **Bibliothèque** — onglet Shared doté d'un filtre « Tous / Par moi / + Avec moi » ; l'endpoint `GET /api/library/shared` accepte `dir=made|received|all` + et renseigne `share_dir` sur chaque élément. +- **Indicateur de partage** — la barre du document affiche « 👥 Shared » + au lieu de « 🔒 Share » dès que la page est partagée (membre, lien ou + publiée) ; état recalculé à la volée après chaque action de partage. +- **Workspace local** — les fichiers partagés affichent l'emoji 👥 juste + avant leur nom dans l'arborescence (`is_shared` exposé par + `/api/local-workspace/tree`). +- **Tests** (tests/test_sharing.py +6) : direction made/received/all de la + bibliothèque, fallback dir invalide, et rendu `pageIsShared` sur la page. + Suite complète 325 verte (+3 PDF pré-existants). + ## v5.4.1 (2026-09-08) — Partage de page : correctif permissions + autocomplete membres > Le partage à un membre existant échouait avec « Invalid permission. Use view, diff --git a/ROADMAP.md b/ROADMAP.md index e86b3ad..c57178c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -636,4 +636,4 @@ Quality DB views, Agent IA Palette → Realtime + S DB avancée, redo, drag&drop, Calendrier, AI duplicate) -*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) ; reste v5.4 → v6.0* +*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) puis v5.5.0 (repo) Partage v2 livré (fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 Shared, 👥 workspace local, 6 tests, suite 325 verte) ; reste v5.4 → v6.0* diff --git a/VERSION b/VERSION index 04edabd..c7ba1e8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.4.1 \ No newline at end of file +5.5.0 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 4c5f98c..031f1bd 100644 --- a/app/main.py +++ b/app/main.py @@ -53,7 +53,7 @@ async def lifespan(_app: FastAPI): from app.services.automations import automation_scheduler automation_task = asyncio.create_task(automation_scheduler()) - logger.info("FlowDeck v5.4.1 started on port %d", settings.app_port) + logger.info("FlowDeck v5.5.0 started on port %d", settings.app_port) try: yield finally: @@ -71,7 +71,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.4.1", + version="5.5.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/board.py b/app/routers/board.py index 1c11313..f2c58a6 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -338,20 +338,36 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "children": [], }) - # Load shared pages (anyone with link or published) + # Load shared pages: made (page_shares created by me + link-mode pages) and + # received (page_shares toward me), plus published pages. with get_conn() as conn: - shared_rows = conn.execute( - "SELECT id, title, workspace, share_mode, published FROM pages " - "WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20" + made_nominal = conn.execute( + "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " + "JOIN pages p ON p.id=s.page_id " + "WHERE s.created_by=? AND p.deleted_at IS NULL", + (uid,), ).fetchall() - shared_pages = [] - published_pages = [] - for r in shared_rows: - page_entry = { + made_link = conn.execute( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL" + ).fetchall() + published_rows = conn.execute( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE published=1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20" + ).fetchall() + received_rows = conn.execute( + "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " + "JOIN pages p ON p.id=s.page_id " + "WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL", + (uid,), + ).fetchall() + + def _shared_entry(r, icon): + return { "id": f"page/{r['id']}", "db_id": r["id"], "name": r["title"] or "New page", - "icon": "🌐" if r["published"] else "🔗", + "icon": icon, "url": f"/pages/{r['id']}", "active": False, "indent": 0, @@ -359,10 +375,17 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "has_children": False, "children": [], } - if r["published"]: - published_pages.append(page_entry) - else: - shared_pages.append(page_entry) + + made_map = {} + for r in (*made_nominal, *made_link): + made_map.setdefault(r["id"], r) + made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20] + shared_made_pages = [_shared_entry(r, "link") for r in made_sorted] + received_sorted = [r for r in received_rows if r["id"] not in made_map] + received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20] + shared_received_pages = [_shared_entry(r, "users") for r in received_sorted] + published_pages = [_shared_entry(r, "globe") for r in published_rows] + shared_pages = [_shared_entry(r, "link") for r in made_sorted] # Auth method & OAuth badge data auth_method = "local" @@ -396,6 +419,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "current_page": repo or "Dashboard", "last_edited": "now", "recent_pages": recent, "private_pages": private_items, "favorite_pages": favorites, "shared_pages": shared_pages, + "shared_made_pages": shared_made_pages, + "shared_received_pages": shared_received_pages, "published_pages": published_pages, "user": user, "auth_method": auth_method, @@ -542,6 +567,8 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s sidebar["favorite_pages"] = [] sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"] sidebar["shared_pages"] = [] + sidebar["shared_made_pages"] = [] + sidebar["shared_received_pages"] = [] template = env.get_template("library.html") return template.render(**sidebar) @@ -1016,6 +1043,7 @@ async def view_page(request: Request, page_id: int): ctx = {**sidebar, "page": page, "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), + "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), "page_data": page_data, "breadcrumb_items": nav_crumbs, "nav_workspace_id": page.get("workspace_id") or 0, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index c97b816..84b0166 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -222,6 +222,8 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = "private_pages": private_pages, "favorite_pages": [], "shared_pages": [], + "shared_made_pages": [], + "shared_received_pages": [], "published_pages": [], "user": user, "avatar_url": avatar_url, @@ -384,6 +386,7 @@ async def view_page_root(request: Request, page_id: int): "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), + "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), "page_data": page_data, "collection_data": collection_data, "breadcrumb_items": nav_crumbs, @@ -1021,7 +1024,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: if parent_id is None: rows = conn.execute( "SELECT id, title, parent_section, content_format, content, " - "created_at, updated_at FROM pages " + "is_shared, 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,), @@ -1029,7 +1032,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: else: rows = conn.execute( "SELECT id, title, parent_section, content_format, content, " - "created_at, updated_at FROM pages " + "is_shared, 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), @@ -1089,6 +1092,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list: "updated_at": r["updated_at"], "author": author, "tags": tags_map.get(r["id"], []), + "is_shared": bool(r["is_shared"]), }) return tree diff --git a/app/routers/library.py b/app/routers/library.py index c3dfcdf..d8aad24 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -84,6 +84,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict: "url": url, "content_format": content_format, "favorited": bool(db_row.get("favorited", 0)), + "share_mode": db_row.get("share_mode", "private"), } @@ -197,13 +198,45 @@ async def library_shared( request: Request, source_type: str = Query(default="all"), tree: int = Query(default=0), + dir: str = Query(default="all"), ): if source_type not in SOURCE_TYPES: source_type = "all" + if dir not in ("made", "received", "all"): + dir = "all" uid = _get_user_id(request) - query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" + + # Page ids the user shares toward others (nominal page_shares) or receives + with get_conn() as conn: + made_rows = conn.execute( + "SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?", + (uid,), + ).fetchall() + recv_rows = conn.execute( + "SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?", + (uid,), + ).fetchall() + made_ids = {r[0] for r in made_rows} + recv_ids = {r[0] for r in recv_rows} + + conds: list[str] = [] params: list = [] + if dir in ("all", "made"): + conds.append("p.share_mode != 'private'") + if dir in ("all", "received") and recv_ids: + conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})") + params.extend(recv_ids) + if dir == "made" and made_ids: + conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})") + params.extend(made_ids) + if dir == "received" and not recv_ids: + return {"items": []} + + query = ( + f"{BASE_SELECT} FROM pages p " + f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" + ) if tree: query += " AND p.parent_id IS NULL" query, params = _apply_source_filter(query, params, source_type) @@ -213,6 +246,11 @@ async def library_shared( rows = conn.execute(query, params).fetchall() items = _rows_to_items(rows, uid) + for it in items: + sid = it["id"] + is_made = sid in made_ids or it.get("share_mode", "private") != "private" + is_recv = sid in recv_ids + it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else "")) _enrich_children(items) return {"items": items} diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 809225c..ee4b385 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -806,6 +806,7 @@ activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}', shareTab:'share',favorited:false, pagePublished:{{ page_published | tojson }}, + pageIsShared:{{ page_is_shared | tojson }}, generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', pubAllowEdit:false,pubAllowComments:true, pageUrl:window.location.href,publishedUrl:'', @@ -1062,7 +1063,7 @@ applyAIBlocks(text){ else{this.activityOpen=false;} }, toggleShareOpen(){ - if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);} + if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);this.loadShares();} else{this.shareOpen=false;} }, toggleMoreOpen(){ @@ -1070,10 +1071,11 @@ applyAIBlocks(text){ else{this.moreOpen=false;} }, _justOpened:false, - togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();}, + _syncIsShared(){this.pageIsShared=!!(this.pagePublished||this.generalAccess==='anyone'||(this.accessList||[]).length>0);}, + togglePublish(){this.pagePublished=!this.pagePublished;this._syncIsShared();this.saveShare();}, saveShare(){ const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{}); + fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{}); }, async copyPageLink(){ console.log('copyPageLink invoked'); @@ -1203,7 +1205,8 @@ applyAIBlocks(text){ var self = this; fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){ self.accessList = d.shares || []; - }).catch(function(){ self.accessList = []; }); + self._syncIsShared(); + }).catch(function(){ self.accessList = []; self._syncIsShared(); }); }, removeShare(sid) { var self = this; diff --git a/app/templates/base.html b/app/templates/base.html index 99b8367..3dfac3a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -393,14 +393,28 @@ diff --git a/app/templates/library.html b/app/templates/library.html index 858f689..4404311 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -88,6 +88,12 @@ .row-title:hover{text-decoration:underline;} .row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;} +/* ── Shared direction sub-filter pils ── */ +.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;} +.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;} +.lib-dir-pill:hover{background:var(--bg-hover);color:var(--text-primary);} +.lib-dir-pill.active{background:var(--accent);border-color:var(--accent);color:#fff;} + /* ── Hover-only elements (hidden until row hover, or when checked/dragging) ── */ .hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;} .lib-row:hover .hover-only,.lib-row.selected .hover-only{opacity:1;} @@ -247,7 +253,7 @@
Show columns
@@ -258,6 +264,13 @@
+ +
+ + + +
+