From 411667fd88eba16027cbcbc7b731b2504caaaa98 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 15 Oct 2026 10:02:45 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20menu=20contextuel=20D=C3=83=C2=A9placer?= =?UTF-8?q?/Dupliquer=20sidebar=20+=20r=C3=83=C2=A9paration=20drag=20&=20d?= =?UTF-8?q?rop=20interne=20#201=20#BUG-117?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 26 +++++++++++- README.fr.md | 6 +-- README.md | 6 +-- VERSION | 2 +- backend/routers/files_write.py | 43 ++++++++++++++++++++ backend/schemas.py | 14 +++++++ backend/services/mutations.py | 74 ++++++++++++++++++++++++++++++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 14 ++++++- frontend/js/dragdrop.js | 9 ++++- frontend/js/ui.js | 74 ++++++++++++++++++++++++++++++++++ frontend/js/upload.js | 8 ++-- frontend/locales/en.json | 6 +++ frontend/locales/fr.json | 6 +++ package.json | 2 +- tests/test_api_main.py | 36 ++++++++++++++++- 19 files changed, 314 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20bafcb..ce1f44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,36 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.68.6**. +> [Unreleased](#unreleased). La dernière version livrée est **2.69.0**. --- ## [Unreleased] +--- + +## [2.69.0] — 2026-10-15 + +### Ajouté + +- **#201 — menu contextuel « Déplacer » / « Dupliquer »** (fichiers *et* dossiers) dans la + sidebar : le modal de destination réutilise `promptTargetFolder` (celui de l'import, même + thème, sélecteur de voute + arbo filtrable), généralisé par un paramètre `opts` optionnel + (titre, résumé, libellé du bouton) ; l'emplacement actuel est pré-sélectionné ; cross-vault + refusé proprement. Nouveau endpoint `POST /api/duplicate/{vault}` (`duplicate_path` : copie + `X copie.ext`, collisions `copie 2`, `copie 3`… ; répertoires récursifs ; refus de dupliquer + un dossier dans lui-même ; index incrémental, SSE + webhooks). i18n FR/EN. + +### Corrigé + +- **BUG-117** — drag & drop interne de la sidebar (déplacement d'un fichier/dossier vers un + autre) inopérant et silencieux depuis #89 : la détection « dépôt externe » dans `onDrop` + testait `items.length > 0`, qui comptait aussi les formats *string* (`tree:*`) posés par + `setData` au `dragstart` interne → chaque déplacement basculait dans la branche upload et + disparaissait sans appel API ni toast. Restreinte aux vrais fichiers (`kind === 'file'`, + type `Files`, `files` non vide), avec priorité au `dragState`. + + --- ## [2.68.6] — 2026-10-14 diff --git a/README.fr.md b/README.fr.md index 8f17024..5bd3711 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.68.6-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.69.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.6). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.69.0). --- -*Projet : ObsiGate | Version : 2.68.6 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.69.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 5ce4203..616b5e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.68.6-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.69.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.6). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.69.0). --- -*Project: ObsiGate | Version: 2.68.6 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.69.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3155ce1..a740b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.68.6 +2.69.0 diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index ab2a3d4..1a5e265 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -34,6 +34,8 @@ from backend.schemas import ( FileCreateRequest, FileCreateResponse, FileDeleteResponse, + FileDuplicateRequest, + FileDuplicateResponse, FileMoveRequest, FileMoveResponse, FileRenameRequest, @@ -55,6 +57,9 @@ from backend.services.mutations import ( from backend.services.mutations import ( delete_file as service_delete_file, ) +from backend.services.mutations import ( + duplicate_path as service_duplicate_path, +) from backend.services.mutations import ( edit_file as service_edit_file, ) @@ -743,3 +748,41 @@ async def api_file_move( await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type} + +@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse) +async def api_file_duplicate( + vault_name: str, + body: FileDuplicateRequest, + current_user=Depends(require_auth), +): + """Duplicate (copy) a file or directory within the same vault.""" + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + result = service_duplicate_path(vault_name, body.source_path, body.destination_dir) + new_path_str = result["new_path"] + item_type = result["item_type"] + + # Index: incrémental pour chaque fichier copié (évite un reload complet). + from backend.services.vaults import get_vault_root + root = get_vault_root(vault_name) + target = root / new_path_str + if item_type == "directory": + for fp in sorted(target.rglob("*")): + if fp.is_file(): + await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/")) + else: + await update_single_file(vault_name, new_path_str) + + await sse_manager.broadcast("item_duplicated", { + "vault": vault_name, + "source_path": result["source_path"], + "new_path": new_path_str, + "item_type": item_type, + }) + await dispatch_webhooks( + "file_created" if item_type == "file" else "directory_created", + {"vault": vault_name, "path": new_path_str}, + ) + + return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type} diff --git a/backend/schemas.py b/backend/schemas.py index 4fd72f9..21bf660 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -580,6 +580,20 @@ class FileMoveResponse(BaseModel): success: bool = Field(description="Whether move succeeded") old_path: str = Field(description="Original path") new_path: str = Field(description="New path after move") + +class FileDuplicateRequest(BaseModel): + """Request to duplicate (copy) a file or directory.""" + + source_path: str = Field(description="Relative path of the item to duplicate") + destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)") + + +class FileDuplicateResponse(BaseModel): + """Response after duplicating a file or directory.""" + + success: bool = Field(description="Whether duplication succeeded") + source_path: str = Field(description="Original path") + new_path: str = Field(description="Path of the created copy") item_type: str = Field(description="Type of item moved: 'file' or 'directory'") diff --git a/backend/services/mutations.py b/backend/services/mutations.py index 5bfbebc..1e188e8 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -1534,6 +1534,80 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d "item_type": item_type, } +def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]: + """Duplicate a file or directory (copy) into *destination_dir* (same vault). + + The copy is named `` copie`` (then ``copie 2``, ``copie 3``… on + collision). Directories are copied recursively. + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), + ``permission_denied`` (403), ``invalid_arguments`` (400) when the + destination is inside the source directory. + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + + source = resolve_safe_path(root, source_path) + if not source.exists(): + raise ServiceError( + f"Source not found: {source_path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": source_path}, + ) + + is_directory = source.is_dir() + dest_clean = (destination_dir or "").strip("/") + if dest_clean: + dest_parent = resolve_safe_path(root, dest_clean) + if not dest_parent.exists() or not dest_parent.is_dir(): + raise ServiceError( + f"Destination directory not found: {destination_dir}", + code="not_found", + status=404, + details={"vault": vault_name, "path": destination_dir}, + ) + else: + dest_parent = root + + if is_directory and str(dest_parent.resolve()) in (str(source.resolve()), str(source.resolve()) + os.sep, str(source.resolve()) + "/"): + raise ServiceError( + "Cannot duplicate a directory inside itself", + code="invalid_arguments", + status=400, + details={"vault": vault_name, "path": source_path}, + ) + + ext = "" if is_directory else source.suffix + base = dest_parent / (source.stem + " copie" + ext) + target = base + n = 2 + while target.exists(): + target = base.parent / (source.stem + " copie " + str(n) + ext) + n += 1 + + try: + if is_directory: + shutil.copytree(source, target) + else: + shutil.copy2(source, target) + except PermissionError as e: + raise ServiceError("Permission denied: cannot duplicate item", code="permission_denied", status=403) from e + + new_rel = _rel(root, target) + item_type = "directory" if is_directory else "file" + logger.info(f"Item duplicated: {vault_name}/{source_path} -> {new_rel}") + return { + "success": True, + "vault": vault_name, + "source_path": _rel(root, source), + "new_path": new_rel, + "item_type": item_type, + } + + + # ── D3. Find & replace ───────────────────────────────────────────────────── diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 670dfa0..579fa4b 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.68.6" +version = "2.69.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index bd08b89..c093c0a 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.68.6" +version = "2.69.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 0039eb2..c124703 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.68.6", + "version": "2.69.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 5697eee..09c6b63 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -224,6 +224,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-114* | Popup « currentPath is not defined » après suppression (ou renommage) d'un répertoire | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (`confirmDeleteDirectory`, renommage inline) | Ouvrir un fichier d'un dossier, clic droit → Supprimer le dossier (ou Renommer) → toast d'erreur `currentPath is not defined` alors que l'opération réussit | Les branches répertoire testaient `currentPath` nu (non déclaré dans ui.js — la variable vit dans `state`) au lieu de `state.currentPath` : `ReferenceError` dans le `try`, avalée par le `catch` → toast. Trois usages corrigés (startsWith ×2, slice ×1) | Vérifié en Playwright (instance 2029, flux réel clic droit → Supprimer sur un dossier contenant le fichier ouvert) : toast « Dossier supprimé (1 fichiers) », 0 fantôme dans /api/vault/{v}/paths, 0 erreur JS ; validate-imports 42 modules, unit.test.mjs 13/13 | | *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `