Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411667fd88 |
+25
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
|
||||
|
||||
@@ -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 ``<stem> copie<ext>`` (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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.68.6"
|
||||
version = "2.69.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
|
||||
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
|
||||
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -353,6 +354,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
+13
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.68.6 | **Dernière mise à jour :** 2026-10-14
|
||||
> **Version :** 2.69.0 | **Dernière mise à jour :** 2026-10-15
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -155,6 +155,18 @@
|
||||
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
|
||||
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
|
||||
|
||||
### 201. Menu contextuel « Déplacer » / « Dupliquer » + réparation du drag & drop interne (sidebar, BUG-117)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-15 | **Statut :** ✅ livré le 2026-10-15
|
||||
- **Description :** déplacement/duplication fichiers et répertoires via le menu contextuel
|
||||
(modal `promptTargetFolder` réutilisé et harmonisé, emplacement actuel pré-sélectionné,
|
||||
cross-vault refusé proprement) ; endpoint `POST /api/duplicate/{vault}` (`duplicate_path` :
|
||||
copie `X copie.ext`, collisions numérotées, refus de dupliquer un dossier dans lui-même,
|
||||
index incrémental, SSE + webhooks) ; drag & drop interne réparé (le drop était avalé en
|
||||
silence par la détection `isExternalFiles` qui comptait les formats string `tree:*` du
|
||||
glisser interne) ; i18n FR/EN ; 4 tests `TestDuplicateApi` + validation Playwright E2E.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
Vendored
+7
-2
@@ -239,9 +239,14 @@ async function onDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isExternalFiles = e.dataTransfer && (
|
||||
// BUG-117: 'items.length > 0' comptait aussi les formats STRING (tree:vault/path/
|
||||
// type) ajoutés par setData au dragstart interne → tout déplacement interne était
|
||||
// classé à tort « upload externe » et avalé silencieusement. Seuls les items de
|
||||
// kind 'file' (ou le type 'Files', ou files non vide) désignent un drop externe,
|
||||
// et un dragState actif (drag interne) prime toujours.
|
||||
const isExternalFiles = !dragState && e.dataTransfer && (
|
||||
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
|
||||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
|
||||
Array.from(e.dataTransfer.items || []).some((it) => it.kind === 'file') ||
|
||||
Array.from(e.dataTransfer.types || []).includes('Files')
|
||||
);
|
||||
|
||||
|
||||
@@ -1826,9 +1826,13 @@ export const ContextMenuManager = {
|
||||
this._addSeparator();
|
||||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
|
||||
this._addSeparator();
|
||||
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
|
||||
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
|
||||
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||||
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
|
||||
} else if (type === 'file') {
|
||||
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
|
||||
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
|
||||
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||||
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
||||
this._addSeparator();
|
||||
@@ -1918,6 +1922,76 @@ export const ContextMenuManager = {
|
||||
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
|
||||
},
|
||||
|
||||
// #BUG-117 — Déplacer / Dupliquer via le modal harmonisé promptTargetFolder
|
||||
// (réutilisé de upload.js, opts pour titre/bouton). Le backend ne supporte
|
||||
// que le même vault ; choix d'un autre vault = toast d'erreur.
|
||||
async _pickDestination(title) {
|
||||
const vault = this._targetVault;
|
||||
const name = this._targetPath.split('/').pop();
|
||||
const parent = this._targetPath.includes('/') ? this._targetPath.slice(0, this._targetPath.lastIndexOf('/')) : '';
|
||||
const { promptTargetFolder } = await import('./upload.js');
|
||||
// Destination pré-sélectionnée = emplacement actuel (sinon '' = racine,
|
||||
// ce qui dupliquait/mouvait à la racine par défaut).
|
||||
const picked = await promptTargetFolder(vault, parent, [], {
|
||||
title: title + ' — ' + name,
|
||||
summaryText: t('ui.move_summary', { name: name }),
|
||||
confirmLabel: title,
|
||||
});
|
||||
if (!picked) return null;
|
||||
if (picked.vault !== vault) {
|
||||
showToast(t('toast.move_cross_vault'), 'error');
|
||||
return null;
|
||||
}
|
||||
return picked;
|
||||
},
|
||||
|
||||
async _moveItem() {
|
||||
const vault = this._targetVault;
|
||||
const path = this._targetPath;
|
||||
const type = this._targetType;
|
||||
let picked = null;
|
||||
try {
|
||||
picked = await this._pickDestination(t('common.move'));
|
||||
if (!picked) return;
|
||||
const result = await api(`/api/move/${encodeURIComponent(vault)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
|
||||
});
|
||||
await refreshSidebarTreePreservingState();
|
||||
const nextPath = result.new_path;
|
||||
if (state.currentVault === vault && state.currentPath && (state.currentPath === path || (type === 'directory' && state.currentPath.startsWith(path + '/')))) {
|
||||
if (type === 'file') {
|
||||
await openFile(vault, nextPath);
|
||||
} else {
|
||||
state.currentPath = nextPath + state.currentPath.slice(path.length);
|
||||
syncActiveFileTreeItem();
|
||||
}
|
||||
}
|
||||
showToast(picked.targetDir ? t('toast.moved', { dir: picked.targetDir }) : t('toast.move_root'), 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message || t('common.error'), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async _duplicateItem() {
|
||||
const vault = this._targetVault;
|
||||
const path = this._targetPath;
|
||||
try {
|
||||
const picked = await this._pickDestination(t('common.duplicate'));
|
||||
if (!picked) return;
|
||||
const result = await api(`/api/duplicate/${encodeURIComponent(vault)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
|
||||
});
|
||||
await refreshSidebarTreePreservingState();
|
||||
showToast(t('toast.duplicated', { name: result.new_path.split('/').pop() }), 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message || t('common.error'), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_copyPath(path) {
|
||||
// Try modern clipboard API first, fall back to execCommand for non-secure contexts
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
|
||||
@@ -232,7 +232,7 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
|
||||
* @param {Array} items - Items to upload
|
||||
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
|
||||
*/
|
||||
export async function promptTargetFolder(initialVault, initialDir = '', items = []) {
|
||||
export async function promptTargetFolder(initialVault, initialDir = '', items = [], opts = {}) {
|
||||
let vaultsList = [];
|
||||
try {
|
||||
const vRes = await api('/api/vaults');
|
||||
@@ -316,12 +316,12 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
|
||||
overlay.innerHTML = `
|
||||
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
|
||||
<div class="obsigate-modal-header">
|
||||
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span>
|
||||
<span class="obsigate-modal-title">📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}</span>
|
||||
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
|
||||
</div>
|
||||
<div class="obsigate-modal-body">
|
||||
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
|
||||
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
|
||||
${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
|
||||
</p>
|
||||
|
||||
<div class="modal-form-group">
|
||||
@@ -352,7 +352,7 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
|
||||
</div>
|
||||
<div class="obsigate-modal-footer">
|
||||
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
|
||||
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button>
|
||||
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(opts.confirmLabel || t('upload.import_button') || 'Importer')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"common.error": "Error",
|
||||
"common.loading": "Loading...",
|
||||
"common.move": "Move",
|
||||
"common.duplicate": "Duplicate",
|
||||
"common.never": "Never",
|
||||
"common.next": "Next",
|
||||
"common.no": "No",
|
||||
@@ -1834,6 +1835,11 @@
|
||||
"toast.no_open_file": "No file open",
|
||||
"toast.not_found_tree": "Item not found in tree",
|
||||
"toast.path_copied": "Path copied: {path}",
|
||||
"ui.move_summary": "Item to move: {name}",
|
||||
"toast.moved": "Moved to: {dir}",
|
||||
"toast.move_root": "Moved to vault root",
|
||||
"toast.duplicated": "Duplicated: {name}",
|
||||
"toast.move_cross_vault": "Cross-vault move/duplicate not supported",
|
||||
"toast.reindex_done": "Reindexing complete",
|
||||
"toast.reindex_started": "Reindexing...",
|
||||
"toast.rename_error": "Rename error",
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"common.error": "Erreur",
|
||||
"common.loading": "Chargement...",
|
||||
"common.move": "Déplacer",
|
||||
"common.duplicate": "Dupliquer",
|
||||
"common.never": "Jamais",
|
||||
"common.next": "Suivant",
|
||||
"common.no": "Non",
|
||||
@@ -1834,6 +1835,11 @@
|
||||
"toast.no_open_file": "Aucun fichier ouvert",
|
||||
"toast.not_found_tree": "Élément introuvable dans l'arborescence",
|
||||
"toast.path_copied": "Chemin copié : {path}",
|
||||
"ui.move_summary": "Élément à déplacer : {name}",
|
||||
"toast.moved": "Déplacé vers : {dir}",
|
||||
"toast.move_root": "Déplacé vers la racine de la voute",
|
||||
"toast.duplicated": "Dupliqué : {name}",
|
||||
"toast.move_cross_vault": "Déplacement/duplication entre voutes non supporté",
|
||||
"toast.reindex_done": "Réindexation terminée",
|
||||
"toast.reindex_started": "Réindexation en cours...",
|
||||
"toast.rename_error": "Erreur lors du renommage",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.68.6",
|
||||
"version": "2.69.0",
|
||||
"description": "**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.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
+35
-1
@@ -1002,4 +1002,38 @@ class TestStaticCaching:
|
||||
def test_static_asset_revalidates(self, client):
|
||||
resp = client.get("/static/style.css")
|
||||
assert resp.status_code == 200
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||
|
||||
|
||||
class TestDuplicateApi:
|
||||
"""BUG-117 : endpoint POST /api/duplicate — copie fichiers/répertoires."""
|
||||
|
||||
def test_duplicate_file_and_collision(self, client):
|
||||
r = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["new_path"] == "note1 copie.md"
|
||||
# collision -> copie 2
|
||||
r2 = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
|
||||
assert r2.json()["new_path"] == "note1 copie 2.md"
|
||||
orig = client.get("/api/file/TestVault", params={"path": "note1.md"}).json()["html"]
|
||||
copy = client.get("/api/file/TestVault", params={"path": "note1 copie.md"}).json()["html"]
|
||||
assert copy == orig
|
||||
|
||||
def test_duplicate_directory_into_target_and_index(self, client):
|
||||
assert client.post("/api/directory/TestVault", json={"path": "notes/sub"}).status_code == 200
|
||||
assert client.post("/api/file/TestVault", json={"path": "notes/a.md", "content": "secret note A"}).status_code == 200
|
||||
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "Projets"})
|
||||
assert r.status_code == 200
|
||||
new = r.json()["new_path"]
|
||||
assert new == "Projets/notes copie"
|
||||
paths = [p["path"] for p in client.get("/api/vault/TestVault/paths").json()["results"]]
|
||||
assert any(p.startswith(new + "/") for p in paths), "copy not indexed"
|
||||
|
||||
def test_duplicate_directory_inside_itself_rejected(self, client):
|
||||
assert client.post("/api/directory/TestVault", json={"path": "notes"}).status_code == 200
|
||||
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "notes"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_duplicate_not_found(self, client):
|
||||
r = client.post("/api/duplicate/TestVault", json={"source_path": "nonexistent.md", "destination_dir": ""})
|
||||
assert r.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user