Compare commits

...
7 Commits
Author SHA1 Message Date
Spark 9bc31d069b feat: barre d'apps commune et références croisées des notes (#215)
CI / lint (push) Successful in 3m32s
CI / security (push) Failing after 2m27s
CI / test (push) Successful in 5m36s
CI / build (push) Successful in 2m12s
CI / e2e (push) Successful in 18m18s
2026-10-20 22:20:48 -04:00
bruno 411667fd88 feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s
2026-10-15 10:02:48 -04:00
bruno ed9029d760 fix: éditeur Forge — textarea qui ne grandit pas après insertion programmatique (tableau/code) #BUG-116
CI / lint (push) Successful in 3m17s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 22:09:28 -04:00
bruno 1892062d88 fix: éditeur Forge — espace checkbox/texte aperçu, flash du preview, menus table/langue coupés #BUG-115
CI / lint (push) Successful in 3m11s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m47s
2026-10-14 21:56:37 -04:00
bruno cbe3d9bc75 fix: popup currentPath is not defined après suppression/renommage de répertoire #BUG-114
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m12s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 21:20:08 -04:00
bruno 2ebfe74459 perf: suppression de répertoire incrémentale au lieu du re-scan complet #BUG-113
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m55s
CI / test (push) Successful in 5m25s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 17m56s
2026-10-14 16:56:56 -04:00
bruno 6257adb46b fix: CI security — bandit B112 sur search_shared_notes (nosec, continue délibéré)
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m56s
CI / test (push) Successful in 5m13s
CI / build (push) Successful in 1m47s
CI / e2e (push) Successful in 18m17s
2026-10-14 13:43:26 -04:00
34 changed files with 1617 additions and 39 deletions
+130 -1
View File
@@ -6,7 +6,7 @@ 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.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.70.0**.
---
@@ -14,6 +14,135 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.70.0] — 2026-10-20
### Ajouté
- **#215 — Fusion bloc 1 : barre d'apps commune et références croisées des
notes** (programme Fusion, issues Gitea #5/#6/#7 ; contrats
`dracodev-commons` v0.1.0) :
- **Barre d'apps commune** en tête de l'interface (snippet figé vendangé :
`frontend/draco-barre.css/.js`, markup dans `index.html`) — navigation
vers ObsiGate, Flowdeck, TikTak et Kanbante, ObsiGate marquée active,
sélecteur de domaine mémorisé ; la recherche, le rail et le menu délèguent
aux contrôles natifs d'ObsiGate.
- **Références croisées `external_ref`** : section « Liens » dans la vue
d'une note — coller une URL profonde (Flowdeck, TikTak, issue Gitea =
carte Kanbante, ObsiGate) crée la référence (libellé automatique ou
libre, pastille de couleur de l'app cible). Backend : stockage JSON par
utilisateur (`backend/external_refs.py`), endpoints `GET/POST /api/refs`
et `DELETE /api/refs/{id}`, parseur d'URL conforme au contrat figé
(422 explicite sinon), schémas et tag OpenAPI « ExternalRefs ».
- **Notes projetées TikTak reconnues** (frontmatter `tiktak_id`) : bandeau
« Tâche TikTak projetée » et bouton « Ouvrir dans TikTak » dans la même
section (module `frontend/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`).
- i18n FR/EN (clés `draco.links.*`) ; tests `test_external_refs.py`.
---
## [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
---
## [2.68.5] — 2026-10-14
---
## [2.68.4] — 2026-10-14
### Corrigé
- **BUG-116** — éditeur Forge : après insertion d'un tableau (ou bloc de code,
diagramme), la textarea restait d'une seule ligne — les mutations
programmatiques de `ta.value` ne déclenchent pas l'événement `input`, seul
chemin qui rappelait `autoHeight()`. `insertAtCursor` et `wrapSelection` (les
deux points de passage de toutes les insertions) appellent désormais
`autoHeight()` + `updateLineNumbers()` — le nombre de lignes du gutter suit.
Vérifié en Playwright : tableau 4 lignes sur document d'une ligne → hauteur
51 → 189 px, gutter cohérent.
- **BUG-115** — éditeur Forge (aperçu et menus) : trois défauts —
(1) collure case à cocher/texte dans l'aperçu des checklists : le HTML du
serveur ne sépare pas l'`<input>` du texte et `editor-poc.html` n'avait pas
la règle `.task-list-item` de `style.css` (ajoutée à son `<style>`) ;
(2) l'aperçu « flashait » : chaque frappe effaçait le rendu pour afficher
« Rendering... » — l'ancien contenu reste désormais affiché pendant le
rafraîchissement, et la dernière frappe est rejouée (flag `pending`) au lieu
d'être perdue ; (3) le menu « dimensions du tableau » (et le sélecteur de
langage) était posé en `position: absolute` avec des coordonnées viewport →
double décalage → menu coupé hors du champ sur un document d'une ligne ;
passés en `position: fixed` avec clamp viewport comme le menu slash, et
`stopPropagation` sur les clics des menus (la bulle fermait la forme
aussitôt ouverte). Vérifié en Playwright sur l'instance E2E : spacing 6 px,
aucun spinner pendant la frappe, forme entièrement visible + insertion OK.
- **BUG-114** — popup « `currentPath is not defined` » après suppression (ou
renommage) d'un répertoire : `frontend/js/ui.js` référençait `currentPath` nu
(non déclaré — la variable vit dans `state`) au lieu de `state.currentPath`
dans les branches répertoire de `confirmDeleteDirectory` et du renommage en
ligne. La `ReferenceError` était avalée par le `catch` et affichée en toast
d'erreur. Trois usages corrigés ; vérifié en Playwright (flux réel clic droit
→ Supprimer) : toast « Dossier supprimé », aucun fantôme, 0 erreur JS.
---
## [2.68.3] — 2026-10-14
### Amélioré
- **BUG-113 — Suppression de répertoire rapide** : `DELETE /api/directory/{vault}`
re-scannait la vault entière et reconstruisait l'index inversé global après le
`rmtree` (`reload_single_vault`) — le temps croissait avec toute la vault, pas
avec le dossier supprimé. Nouveau chemin incrémental
(`remove_directory_from_index`) qui retire uniquement les fichiers du
sous-arbre (index, paths, index inversé et sémantique, dossiers fantômes
purgés). Mesuré sur l'instance E2E : 60 fichiers supprimés en **63 ms** au
lieu de **668 ms** (~10×, écart qui grandit avec la taille du home-user).
Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans
paths ni dans la recherche plein-texte après suppression).
---
## [2.68.2] — 2026-10-14
### Corrigé
- **CI security — bandit bloquant sur B112** : `backend/services/search.py`
(`search_shared_notes` : un partage illisible était ignoré via
`except Exception: continue`) déclenchait `B112:try_except_continue`, le job
`security` sortait en rouge. Le `continue` est **délibéré** (un partage
inaccessible ne doit pas faire échouer la recherche) — `# nosec B112` posé
sur la ligne, comme les `# nosec B110` existants (`watcher.py`, `B105`,
`B324`). Bandit re-vérifié localement : 0 issue ; l'étape reste bloquante
(garde-fou `test_bandit_and_pip_audit_stay_blocking` intact).
---
## [2.68.1] — 2026-10-14
### Corrigé
+3 -3
View File
@@ -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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.70.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.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.70.0).
---
*Projet : ObsiGate | Version : 2.68.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.70.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.70.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.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.70.0).
---
*Project: ObsiGate | Version: 2.68.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.70.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.68.1
2.70.0
+172
View File
@@ -0,0 +1,172 @@
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
Modèle figé par le programme Fusion dans ``dracodev-commons``
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
par ``(vault, path)`` — et pointe vers un objet d'une autre application
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
seule source de vérité par objet : on stocke des références, jamais
des copies du contenu cible.
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
patron que :mod:`backend.history` (pas de base de données, cf.
``AGENTS.md``). Le répertoire est surchargeable par la variable
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
"""
import json
import logging
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
def _refs_dir() -> Path:
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
directory = Path(override) if override else Path("data/external_refs")
directory.mkdir(parents=True, exist_ok=True)
return directory
def _refs_file(username: str) -> Path:
return _refs_dir() / f"{username}_refs.json"
def _read_refs(username: str) -> list[dict[str, Any]]:
file = _refs_file(username)
if not file.exists():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as e:
logger.error(f"Failed to read external refs from {file.name}: {e}")
return []
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
file = _refs_file(username)
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
except Exception as e:
logger.error(f"Failed to write external refs to {file.name}: {e}")
# ------------------------------------------------------------ parseur d'URL
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
"""Reconnaît une URL profonde des applications dracodev.
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
"""
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net":
q = parse_qs(u.query)
if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None
if host == "og.dracodev.net":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
if ":" in cible:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if host == "flowdeck.dracodev.net":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if host == "git.dracodev.net":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
return None
return None
# ------------------------------------------------------------ CRUD
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
"""Références posées sur une note, dans l'ordre de création."""
if not username:
return []
return [
r for r in _read_refs(username)
if r.get("owner_vault") == vault and r.get("owner_path") == path
]
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Une référence par son identifiant, ou ``None``."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return r
return None
def add_ref(
username: str,
vault: str,
path: str,
target_app: str,
target_type: str,
target_id: str,
target_url: str,
label: str,
) -> dict[str, Any]:
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
refs = _read_refs(username)
ref = {
"id": uuid.uuid4().hex,
"owner_type": "obsigate.note",
"owner_id": f"{vault}:{path}",
"owner_vault": vault,
"owner_path": path,
"target_app": target_app,
"target_type": target_type,
"target_id": target_id,
"target_url": target_url,
"label": label,
"domain": None,
"status": "ok",
"created_by": "user",
"created_at": datetime.now(UTC).isoformat(),
}
refs.append(ref)
_write_refs(username, refs)
return ref
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Retire une référence ; retourne la référence retirée ou ``None``."""
refs = _read_refs(username)
kept = [r for r in refs if r.get("id") != ref_id]
if len(kept) == len(refs):
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return removed
+46
View File
@@ -1312,6 +1312,52 @@ async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict
return await update_single_file(vault_name, dest_abs)
async def remove_directory_from_index(vault_name: str, dir_rel_path: str) -> int:
"""Remove a directory subtree from the index without a full vault re-scan.
Incremental equivalent of ``reload_single_vault`` for deletions : every
indexed file under *dir_rel_path* is dropped via
``_remove_file_from_structures`` (which notifies the inverted and semantic
indexes through the change hook), and the subtree's directory entries are
purged from ``path_index``. Returns the number of files removed.
Used by :func:`api_directory_delete` — previously a ``rmtree`` was followed
by a full vault re-scan + global inverted-index rebuild, which is linear in
the whole vault size on every single directory deletion.
Args:
vault_name: Name of the vault.
dir_rel_path: Directory path relative to the vault root.
Returns:
Number of indexed files removed.
"""
vault_data = index.get(vault_name)
if not vault_data:
return 0
prefix = dir_rel_path.rstrip("/") + "/"
targets = [f["path"] for f in vault_data.get("files", []) if f["path"].startswith(prefix)]
lock = _get_async_lock()
async with lock:
removed = 0
for rel_path in targets:
if _remove_file_from_structures(vault_name, rel_path):
removed += 1
# Purge the subtree's directory entries too, otherwise empty dirs stay
# in path_index as ghosts (file entries were removed per-file above).
if vault_name in path_index:
path_index[vault_name] = [
p for p in path_index[vault_name]
if not (p["path"] == dir_rel_path or p["path"].startswith(prefix))
]
if removed:
logger.info(f"Directory removed from index: {vault_name}/{dir_rel_path} ({removed} files)")
return removed
async def remove_vault_from_index(vault_name: str):
"""Remove an entire vault from the index.
+2
View File
@@ -522,6 +522,7 @@ from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router
from backend.routers.external_refs import router as external_refs_router
from backend.routers.file_chat import router as file_chat_router
from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
@@ -543,6 +544,7 @@ app.include_router(bookslm_router)
app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
+2
View File
@@ -28,6 +28,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
@@ -114,6 +115,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/graph"), "Search"),
(re.compile(r"^/api/recent"), "Bookmarks"),
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
(re.compile(r"^/api/refs"), "ExternalRefs"),
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
(re.compile(r"^/api/vaults"), "Vaults"),
(re.compile(r"^/api/vault/"), "Vaults"),
+84
View File
@@ -0,0 +1,84 @@
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
Création par collage d'une URL profonde d'une autre application
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
l'URL est reconnue par les formes figées du contrat
``dracodev-commons/contrats/liens-profonds.md`` (voir
:mod:`backend.external_refs`), la référence est stockée chez la note
propriétaire et affichée dans sa section « Liens ».
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["ExternalRefs"])
_FORMES_ACCEPTEES = (
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
"issue Gitea (carte Kanbante)"
)
@router.get("/api/refs", response_model=ExternalRefsResponse)
async def api_list_refs(
vault: str = Query(...),
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Références croisées posées sur une note."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
username = current_user.get("username") or ""
return {"refs": list_refs(username, vault, path)}
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
async def api_add_ref(
payload: ExternalRefCreateRequest,
current_user=Depends(require_auth),
):
"""Crée une référence sur une note à partir d'une URL profonde collée."""
if not check_vault_access(payload.vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
parsed = parse_deep_link(payload.url)
if parsed is None:
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
target_app, target_type, target_id, default_label = parsed
label = (payload.label or "").strip() or default_label
username = current_user.get("username") or ""
return add_ref(
username,
payload.vault,
payload.path,
target_app,
target_type,
target_id,
payload.url.strip(),
label,
)
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
async def api_delete_ref(
ref_id: str,
current_user=Depends(require_auth),
):
"""Retire une référence d'une note."""
username = current_user.get("username") or ""
ref = get_ref(username, ref_id)
if ref is None:
raise HTTPException(status_code=404, detail="Lien introuvable")
if not check_vault_access(ref["owner_vault"], current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
removed = delete_ref(username, ref_id)
if removed is None: # course improbable : disparue entre get et delete
raise HTTPException(status_code=404, detail="Lien introuvable")
return removed
+47 -3
View File
@@ -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,
)
@@ -532,9 +537,10 @@ async def api_directory_delete(
result = service_delete_directory(vault_name, path, recursive=True)
file_count = result["deleted_count"]
# Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Update index — incremental subtree removal (no full vault re-scan, no
# global inverted-index rebuild).
from backend.indexer import remove_directory_from_index
await remove_directory_from_index(vault_name, result["path"])
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
@@ -742,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}
+52
View File
@@ -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'")
@@ -605,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
title: str | None = None
class ExternalRef(BaseModel):
"""Référence croisée d'une note vers un objet d'une autre application.
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
v0.1.0) — Fusion #215 (ACC-1.3).
"""
id: str
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
owner_vault: str
owner_path: str
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
target_type: str = Field(description="note | project | page | task | list | card")
target_id: str
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
label: str = Field(description="Instantané d'affichage du titre de la cible")
domain: str | None = None
status: str = Field(default="ok", description="ok | target_missing")
created_by: str = Field(default="user")
created_at: str
class ExternalRefCreateRequest(BaseModel):
"""Création d'une référence par collage d'URL profonde."""
vault: str
path: str
url: str
label: str | None = None
class ExternalRefsResponse(BaseModel):
"""Liste des références d'une note."""
refs: list[ExternalRef]
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
+74
View File
@@ -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 ─────────────────────────────────────────────────────
+1 -1
View File
@@ -80,7 +80,7 @@ def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
if not fp.exists() or fp.suffix.lower() != ".md":
continue
raw = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
except Exception: # nosec B112 — partage illisible : ignoré, on continue
continue
title = fp.stem
occurrences = raw.lower().count(q_lower)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.68.1"
version = "2.70.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.68.1"
version = "2.70.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.68.1",
"version": "2.70.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+10 -2
View File
@@ -220,8 +220,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
| *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 |
| *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 |
|---|---|---|---|---|---|---|---|---|---|
@@ -347,6 +350,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
| 2026-10-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) |
| 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) |
---
+39 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.68.1 | **Dernière mise à jour :** 2026-10-14
> **Version :** 2.70.0 | **Dernière mise à jour :** 2026-10-20
> **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)**
@@ -30,6 +30,32 @@
## 🔵 En cours
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
- **Fiche :** [features/fusion-bloc1-barre-refs-215.md](./features/fusion-bloc1-barre-refs-215.md)
- **Description :** première tranche du programme **Fusion** (accès centralisé
ObsiGate / Flowdeck / TikTak / Kanbante — dépôt programme
`Projets/fusion-trois-apps`, épopée ACC-1 ; contrats versionnés dans
`Projets/dracodev-commons` v0.1.0). ID 215 choisi hors des plages déjà
utilisées ou proposées (#191–#201 livrées/Assistant V2, #202–#214
proposées par ASSISTANT-V2.1). Issues Gitea ObsiGate #5 (ACC-1.1),
#6 (ACC-1.2), #7 (ACC-1.3).
- **Sous-tâches :**
- [x] Barre d'apps commune vendangée du snippet figé de commons
(`frontend/draco-barre.css/.js`, markup dans `index.html`) :
ObsiGate marquée active, navigation vers les 4 apps, domaine mémorisé
- [x] Backend `external_ref` : stockage JSON par utilisateur
(`backend/external_refs.py`), endpoints `GET/POST /api/refs` et
`DELETE /api/refs/{id}` (schémas + tag OpenAPI), parseur d'URL
profondes conforme au contrat figé
- [x] Section « Liens » dans la vue d'une note (module
`frontend/js/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`)
- [x] Notes projetées TikTak reconnues (frontmatter `tiktak_id`) :
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
@@ -155,6 +181,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)
@@ -0,0 +1,73 @@
# #215 — Fusion bloc 1 : barre d'apps commune et références croisées des notes
> Programme **Fusion** (accès centralisé entre ObsiGate, Flowdeck, TikTak et
> Kanbante). Dépôt programme : `Projets/fusion-trois-apps` (épopée ACC-1,
> issues ObsiGate #5, #6, #7). Contrats : `Projets/dracodev-commons` v0.1.0
> (`contrats/liens-profonds.md`, `contrats/external_ref.schema.json`,
> snippet `ui/barre-apps/`).
## Ce qui est livré
### ACC-1.1 — Barre d'apps commune (#5)
Le snippet figé de commons est **vendangé** (copié, jamais chargé depuis une
autre app — la barre survit à l'arrêt des autres applications) :
- `frontend/draco-barre.css` / `frontend/draco-barre.js` : copie v0.1.0 ;
- markup + configuration dans `frontend/index.html`, au-dessus de `#app`
(`body.has-draco-bar` ajuste la hauteur de `.app-container` dans
`style.css`).
Phase 1 : navigation entre les quatre applications (ObsiGate marquée
active), sélecteur de domaine mémorisé en `localStorage` (`draco_domain`,
même clé que TikTak), recherche = focus de la recherche native, rail =
bouton latéral natif, menu = menu d'en-tête natif. Alertes unifiées et
panneau Spark affichent un message d'arrivée (fédération et agent commun :
phases ultérieures du programme) plutôt que des boutons morts. Les compteurs
croisés viendront avec la fédération ; seul le compteur local de chaque app
est alimenté en phase 1.
### ACC-1.3 — Références croisées `external_ref` (#7)
- **Modèle** : le schéma figé de commons. La note est propriétaire de ses
références (`owner_type = obsigate.note`, `owner_id = "<vault>:<chemin>"`).
- **Stockage** : JSON par utilisateur dans `data/external_refs/`
(`backend/external_refs.py`, même patron que `backend/history.py` —
pas de base de données). Surchargable par `OBSIGATE_EXTERNAL_REFS_DIR`.
- **API** (auth + `check_vault_access`) :
- `GET /api/refs?vault=&path=` — références d'une note ;
- `POST /api/refs` `{vault, path, url, label?}` — création par collage
d'URL profonde ; le parseur reconnaît les formes du contrat (TikTak
`?task=`/`?list=`, ObsiGate `#file=`, Flowdeck `/pages/`/`/collections/`,
issue Gitea = carte Kanbante) et répond **422** explicite sinon ;
- `DELETE /api/refs/{id}`.
- **UI** : section « Liens » en fin de note — liste avec pastille de couleur
de l'app cible, formulaire d'ajout, retrait en un clic. Rendue par le
module autonome `frontend/draco-liens.js`, branché sur l'événement
`obsigate:file-rendered` que `renderFile` émet (point d'extension
documenté dans `frontend/app.js`).
### ACC-1.2 — Notes projetées TikTak (#6)
La synchro TikTak ↔ ObsiGate écrit `tiktak_id: <id>` dans le frontmatter des
fichiers projetés. Le module `draco-liens.js` le détecte (lecture du brut via
`/api/file/{vault}/raw`) et affiche un bandeau « Tâche TikTak projetée » avec
le bouton **« Ouvrir dans TikTak »** (`https://tiktak.dracodev.net/?task=<id>`),
au lieu de traiter la note comme une note ordinaire.
## Limites assumées (v1)
- Le libellé d'une référence est un instantané (titre par défaut dérivé de
l'URL ou libellé libre) ; le rafraîchissement automatique du titre viendra
avec la fédération MCP (phase 3 du programme).
- Un lien dont la cible a disparu n'est pas détecté automatiquement
(`status` reste `ok`) — la marque « cible introuvable » viendra avec la
couche données.
- La section « Liens » n'apparaît que dans la vue lecture d'une note
(pas dans l'éditeur).
## Tests
`tests/test_external_refs.py` : parseur (6 formes reconnues, 7 refusées),
stockage (cycle + isolation par utilisateur), API (cycle complet, 422, 404,
403 vault inconnu).
+7
View File
@@ -3424,6 +3424,13 @@
// Initialize outline/TOC for this document
OutlineManager.init();
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
// draco-liens.js rend la section « Liens » de la note
// (références croisées + « ouvrir dans TikTak » si projetée).
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
detail: { vault: data.vault, path: data.path },
}));
}
// ---------------------------------------------------------------------------
+127
View File
@@ -0,0 +1,127 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
.draco-brand-mark { color: var(--agent); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
en barre basse propre à chaque app (patron du prototype v5+). */
.draco-apptabs, .draco-domaines-lbl { display: none; }
.draco-bar { height: 52px; }
}
+56
View File
@@ -0,0 +1,56 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+187
View File
@@ -0,0 +1,187 @@
/* Section « Liens » des notes — Fusion #215 (ACC-1.2 + ACC-1.3).
*
* Module autonome (vanilla, zéro dépendance, zéro build) : il écoute
* l'événement « obsigate:file-rendered » émis par renderFile
* (frontend/app.js) et rend sa section à la fin de #content-area :
*
* - ACC-1.2 : si la note est une projection TikTak (frontmatter
* « tiktak_id » écrit par la synchro TikTak ↔ ObsiGate), un bandeau
* « Tâche TikTak projetée » avec le bouton « Ouvrir dans TikTak »
* (lien profond https://tiktak.dracodev.net/?task=<id>) ;
* - ACC-1.3 : les références croisées de la note (GET /api/refs),
* ajout par collage d'une URL profonde, retrait en un clic.
*
* Textes via window.t (i18n FR/EN — clés « draco.links.* » des locales).
*/
(function () {
"use strict";
var COLORS = {
obsigate: "#7a5af5",
flowdeck: "#2f6fed",
tiktak: "#e8762c",
kanbante: "#0d9488",
};
function t(key, fallback) {
try {
var v = window.t && window.t(key);
return v && v !== key ? v : fallback;
} catch (e) {
return fallback;
}
}
function authHeaders() {
var tok = null;
try {
tok = sessionStorage.getItem("obsigate_access_token");
} catch (e) { /* stockage indisponible */ }
return tok ? { Authorization: "Bearer " + tok } : {};
}
async function api(path, opts) {
var options = opts || {};
options.headers = Object.assign(
{ "Content-Type": "application/json" },
authHeaders(),
options.headers || {}
);
options.credentials = "same-origin";
var resp = await fetch(path, options);
if (!resp.ok) {
var detail = "";
try {
detail = (await resp.json()).detail || "";
} catch (e) { /* corps non JSON */ }
throw new Error(detail || "HTTP " + resp.status);
}
return resp.json();
}
function el(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
async function render(vault, path) {
var area = document.getElementById("content-area");
if (!area || !vault || !path) return;
var old = document.getElementById("draco-liens");
if (old) old.remove();
var section = el("section", "draco-liens");
section.id = "draco-liens";
var title = el("h3", "draco-liens-title", t("draco.links.title", "Liens"));
section.appendChild(title);
area.appendChild(section);
// ACC-1.2 — note projetée depuis TikTak ?
if (/\.md$/i.test(path)) {
try {
var rawUrl = "/api/file/" + encodeURIComponent(vault) + "/raw?path=" +
encodeURIComponent(path);
var rawData = await api(rawUrl);
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(rawData.raw || "");
var tid = m && /^tiktak_id:\s*(\d+)\s*$/m.exec(m[1]);
if (tid) {
var bandeau = el("div", "draco-liens-projete");
bandeau.appendChild(el("span", "draco-liens-badge",
t("draco.links.projected", "Tâche TikTak projetée")));
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
ouvrir.target = "_blank";
ouvrir.rel = "noopener";
bandeau.appendChild(ouvrir);
section.appendChild(bandeau);
}
} catch (e) { /* la détection est opportuniste : silence */ }
}
// ACC-1.3 — références croisées de la note
var list = el("ul", "draco-liens-list");
section.appendChild(list);
var erreur = el("p", "draco-liens-erreur");
erreur.hidden = true;
section.appendChild(erreur);
async function refresh() {
list.innerHTML = "";
try {
var data = await api("/api/refs?vault=" + encodeURIComponent(vault) +
"&path=" + encodeURIComponent(path));
(data.refs || []).forEach(function (r) {
var li = el("li", "draco-liens-item");
var dot = el("span", "draco-ref-dot");
dot.style.background = COLORS[r.target_app] || "#888";
li.appendChild(dot);
var a = el("a", "", r.label);
a.href = r.target_url;
a.target = "_blank";
a.rel = "noopener";
li.appendChild(a);
var del = el("button", "draco-liens-del", "✕");
del.type = "button";
del.title = t("draco.links.remove", "Retirer le lien");
del.addEventListener("click", async function () {
try {
await api("/api/refs/" + encodeURIComponent(r.id), { method: "DELETE" });
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
li.appendChild(del);
list.appendChild(li);
});
} catch (e) { /* section facultative : ne jamais casser la note */ }
}
var form = el("form", "draco-liens-form");
var urlInput = el("input");
urlInput.type = "url";
urlInput.required = true;
urlInput.placeholder = t("draco.links.urlPlaceholder",
"Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…");
var labelInput = el("input");
labelInput.type = "text";
labelInput.placeholder = t("draco.links.labelPlaceholder", "Libellé (optionnel)");
var submit = el("button", "btn-action", t("draco.links.add", "Lier"));
submit.type = "submit";
form.appendChild(urlInput);
form.appendChild(labelInput);
form.appendChild(submit);
form.addEventListener("submit", async function (ev) {
ev.preventDefault();
erreur.hidden = true;
try {
await api("/api/refs", {
method: "POST",
body: JSON.stringify({
vault: vault,
path: path,
url: urlInput.value.trim(),
label: labelInput.value.trim() || null,
}),
});
urlInput.value = "";
labelInput.value = "";
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
section.appendChild(form);
refresh();
}
document.addEventListener("obsigate:file-rendered", function (ev) {
if (ev.detail) render(ev.detail.vault, ev.detail.path);
});
})();
+30 -9
View File
@@ -88,6 +88,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.preview-content p { margin: 6px 0; }
.preview-content ul, .preview-content ol { margin: 6px 0; padding-left: 24px; }
.preview-content li { margin: 2px 0; }
.preview-content .task-list-item { list-style: none; margin-left: -1.2em; }
.preview-content .task-list-item input[type="checkbox"] { margin-right: 6px; }
.preview-content code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--ai); }
.preview-content pre { background: var(--bg3); padding: 12px 16px; border-radius: var(--R); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); }
.preview-content pre code { background: none; padding: 0; color: var(--text); }
@@ -134,7 +136,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.qi-item:hover { background: var(--bg-hover); color: var(--text); }
/* Inline form (table dims, code lang) */
.inline-form { position: absolute; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
.inline-form { position: fixed; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
.inline-form.on { display: block; animation: fadeIn 100ms; }
.inline-form-label { font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
.inline-form-row { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
@@ -148,7 +150,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.inline-form-actions button.ok:hover { background: #5a8fc5; }
/* Lang picker (in QI menu style) */
.lang-picker { position: absolute; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
.lang-picker { position: fixed; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
.lang-picker.on { display: block; animation: fadeIn 100ms; }
.lang-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); font-family: var(--mono); }
.lang-item:hover, .lang-item.sel { background: var(--bg-hover); color: var(--text); }
@@ -498,6 +500,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
ta.value = val().substring(0, p.s) + text + val().substring(p.e);
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
setPos(np);
// Mutation programmatique : pas d'evenement 'input' → sans autoHeight/updateLineNumbers,
// sinon la textarea reste d'une ligne quand on insère un tableau/code.
autoHeight();
updateLineNumbers();
markDirty();
}
@@ -507,6 +513,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var sel = val().substring(p.s, p.e);
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
setPos(p.s + before.length + sel.length + (after || before).length);
autoHeight();
updateLineNumbers();
markDirty();
}
@@ -764,6 +772,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
// ---- Markdown Preview (server-side rendering via ObsiGate API) ----
var previewTimer = null;
var _previewLoading = false;
var _previewDirty = false;
function schedulePreview() {
if (!previewVisible) return;
clearTimeout(previewTimer);
@@ -772,7 +781,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function renderPreview() {
if (!previewVisible) return;
if (_previewLoading) return;
if (_previewLoading) { _previewDirty = true; return; }
// Demo mode: fallback to client-side marked
if (!fileVault || !filePath) {
try {
@@ -784,7 +793,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
// Save first, then fetch server-rendered HTML
_previewLoading = true;
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
// Ne jamais effacer le rendu affiché : un update garde l'ancien contenu
// jusqu'à l'arrivée du nouveau (sinon l'aperçu flashe à chaque frappe).
if (!previewContent.firstChild) {
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
}
var content = val();
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath) + '&backup=false', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
@@ -799,6 +812,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
previewContent.innerHTML = data.html || '<div class="preview-loading">Empty document</div>';
processPreviewBlocks();
_previewLoading = false;
if (_previewDirty) { _previewDirty = false; schedulePreview(); }
}).catch(function(e) {
previewContent.innerHTML = '<div class="preview-loading">Erreur de rendu</div>';
_previewLoading = false;
@@ -1082,8 +1096,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
ifCols.value = '3'; ifRows.value = '3';
var pt = caretToPixel(null);
var wr = editorWrap.getBoundingClientRect();
inlineForm.style.left = (wr.left + pt.x) + 'px';
inlineForm.style.top = (wr.top + pt.y + 8) + 'px';
var _ftop = wr.top + pt.y + 8, _fleft = wr.left + pt.x;
if (_ftop + 130 > window.innerHeight) _ftop = window.innerHeight - 140;
if (_fleft + 210 > window.innerWidth) _fleft = window.innerWidth - 220;
inlineForm.style.left = _fleft + 'px';
inlineForm.style.top = _ftop + 'px';
inlineForm.classList.add('on');
ifCols.focus(); ifCols.select();
}
@@ -1130,8 +1147,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
langPicker.innerHTML = html;
var pt = caretToPixel(null);
var wr = editorWrap.getBoundingClientRect();
langPicker.style.left = (wr.left + pt.x) + 'px';
langPicker.style.top = (wr.top + pt.y + 8) + 'px';
var _ltop = wr.top + pt.y + 8, _lleft = wr.left + pt.x;
if (_ltop + 320 > window.innerHeight) _ltop = window.innerHeight - 330;
if (_lleft + 200 > window.innerWidth) _lleft = window.innerWidth - 210;
langPicker.style.left = _lleft + 'px';
langPicker.style.top = _ltop + 'px';
langPicker.classList.add('on');
// Click handler
langPicker.querySelectorAll('.lang-item').forEach(function(item) {
@@ -1428,7 +1448,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
slashSearch.addEventListener('input', function() { filterSlash(slashSearch.value); });
slashMenu.addEventListener('click', function(e) {
var item = e.target.closest('.slash-item');
if (item) selectSlashAction(item.getAttribute('data-action'));
if (item) { e.stopPropagation(); selectSlashAction(item.getAttribute('data-action')); }
});
function navSlash(dir) {
var vis = getVisibleSlash(); if (vis.length === 0) return;
@@ -1452,6 +1472,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
document.getElementById('qi-btn').addEventListener('click', function(e) { e.stopPropagation(); toggleQI(); });
qiMenu.addEventListener('click', function(e) {
var item = e.target.closest('.qi-item'); if (!item) return;
e.stopPropagation();
var action = item.getAttribute('data-qi');
qiMenu.classList.remove('on');
selectSlashAction(action);
+112 -1
View File
@@ -75,6 +75,7 @@
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -222,7 +223,7 @@
};
</script>
</head>
<body>
<body class="has-draco-bar">
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
sans ce splash, le squelette HTML brut serait visible pendant le
@@ -329,6 +330,114 @@
</div>
<!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" aria-label="dracodev">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script>
/* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
window.DRACO_BAR_CONFIG = {
current: "obsigate",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20000;background:#161b22;color:#e6edf3;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
/* Avatar : initiale de l'utilisateur courant (best effort). */
window.addEventListener("DOMContentLoaded", function () {
var tok = null;
try { tok = sessionStorage.getItem("obsigate_access_token"); } catch (e) { }
fetch("/api/auth/me", {
credentials: "same-origin",
headers: tok ? { Authorization: "Bearer " + tok } : {}
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (u) {
var nom = u && (u.display_name || u.username);
if (nom) {
var av = document.getElementById("draco-avatar");
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
}
}).catch(function () { /* avatar générique conservé */ });
});
</script>
<div id="app" class="app-container">
<!-- Header -->
<header class="header">
@@ -5931,5 +6040,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
</div>
</div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
</body>
</html>
+7 -2
View File
@@ -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')
);
+77 -3
View File
@@ -964,8 +964,8 @@ const FileOperations = {
if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
await openFile(vault, nextPath);
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) {
const suffix = state.currentPath === path ? '' : currentPath.slice(path.length);
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || state.currentPath.startsWith(`${path}/`))) {
const suffix = state.currentPath === path ? '' : state.currentPath.slice(path.length);
state.currentPath = `${nextPath}${suffix}`;
await focusPathInSidebar(vault, state.currentPath, { alignToTop: false });
}
@@ -1051,7 +1051,7 @@ const FileOperations = {
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) {
if (state.currentVault === vault && state.currentPath && state.currentPath.startsWith(path)) {
showWelcome();
}
} catch (err) {
@@ -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) {
+4 -4
View File
@@ -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>
`;
+13
View File
@@ -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",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Or enter a new subfolder path...",
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
"upload.import_button": "Import",
"draco.links.add": "Link",
"draco.links.labelPlaceholder": "Label (optional)",
"draco.links.openInTikTak": "Open in TikTak",
"draco.links.projected": "Projected TikTak task",
"draco.links.remove": "Remove link",
"draco.links.title": "Links",
"draco.links.urlPlaceholder": "Paste a deep link (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Cancel",
"editor.close": "Close",
"editor.delete_error": "Delete error",
@@ -1834,6 +1842,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",
+13
View File
@@ -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",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
"upload.import_button": "Importer",
"draco.links.add": "Lier",
"draco.links.labelPlaceholder": "Libellé (optionnel)",
"draco.links.openInTikTak": "Ouvrir dans TikTak",
"draco.links.projected": "Tâche TikTak projetée",
"draco.links.remove": "Retirer le lien",
"draco.links.title": "Liens",
"draco.links.urlPlaceholder": "Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Annuler",
"editor.close": "Fermer",
"editor.delete_error": "Erreur de suppression",
@@ -1834,6 +1842,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",
+39
View File
@@ -14292,3 +14292,42 @@ textarea.file-chat-input {
/* — Barre d'apps commune dracodev + section « Liens » des notes
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
@media (max-width: 700px) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
margin: 28px 0 8px; padding: 16px 18px;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-secondary);
}
.draco-liens-title { margin: 0 0 10px; font-size: 15px; color: var(--text-primary); }
.draco-liens-projete {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
background: var(--bg-hover);
}
.draco-liens-badge { font-weight: 700; color: #e8762c; font-size: 13px; }
.draco-liens-ouvrir { text-decoration: none; }
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
.draco-ref-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 7px; flex: none;
}
.draco-liens-item a { color: var(--text-primary); }
.draco-liens-del {
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
color: var(--text-secondary); font-size: 12px;
}
.draco-liens-del:hover { color: var(--err); }
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
.draco-liens-form input {
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-primary);
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.68.1",
"version": "2.70.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": {
+71 -1
View File
@@ -340,6 +340,42 @@ class TestDirectoryCRUD:
data = resp.json()
assert data["success"] is True
def test_delete_directory_subtree_clears_index(self, client):
"""BUG-113 : suppression incrémentale — aucun fantôme dans paths ni
dans l'index inversé, sans re-scan complet de la vault."""
# Create files inside the directory
for name in ("alpha.md", "nested/beta.md"):
r = client.post("/api/file/TestVault", json={
"path": f"Projets/{name}",
"content": f"contenu unique {name}",
})
assert r.status_code == 200
# Indexed before deletion
paths_before = client.get("/api/vault/TestVault/paths").json()["results"]
assert any(p["path"] == "Projets/alpha.md" for p in paths_before)
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
results = hit.get("results", hit if isinstance(hit, list) else [])
assert any(r["path"] == "Projets/alpha.md" for r in results)
# Delete the whole directory
resp = client.delete("/api/directory/TestVault", params={"path": "Projets"})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["deleted_count"] >= 2
# No ghost in flat paths
paths_after = client.get("/api/vault/TestVault/paths").json()["results"]
ghost_dirs = [p["path"] for p in paths_after if p["path"].startswith("Projets")]
assert ghost_dirs == [], f"fantômes dans paths : {ghost_dirs}"
# No ghost in inverted index (full-text search)
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
results = hit.get("results", hit if isinstance(hit, list) else [])
assert all(r["path"] != "Projets/alpha.md" for r in results)
assert all(r["path"] != "Projets/nested/beta.md" for r in results)
# ═══════════════════════════════════════════════════════════════════
# Search & Tags (supplement existing tests)
@@ -966,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
+131
View File
@@ -0,0 +1,131 @@
"""Références croisées des notes — Fusion #215 (ACC-1.3).
Parseur d'URL profondes (formes figées de dracodev-commons
``contrats/liens-profonds.md``), stockage JSON par utilisateur et
endpoints ``/api/refs`` (création par collage d'URL, liste, retrait).
"""
import pytest
from backend.external_refs import (
add_ref,
delete_ref,
get_ref,
list_refs,
parse_deep_link,
)
@pytest.fixture()
def refs_env(tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
return tmp_path
# ------------------------------------------------------------ parseur
@pytest.mark.parametrize("url,attendu", [
("https://tiktak.dracodev.net/?task=42",
("tiktak", "task", "42", "TikTak · tâche 42")),
("https://tiktak.dracodev.net/?list=7",
("tiktak", "list", "7", "TikTak · liste 7")),
("https://og.dracodev.net/#file=home-bruno:Projets%2FAlpha.md",
("obsigate", "note", "home-bruno:Projets/Alpha.md", "ObsiGate · Alpha.md")),
("https://flowdeck.dracodev.net/pages/15",
("flowdeck", "page", "15", "Flowdeck · page 15")),
("https://flowdeck.dracodev.net/collections/3",
("flowdeck", "project", "3", "Flowdeck · projet 3")),
("https://git.dracodev.net/Projets/fusion-trois-apps/issues/5",
("kanbante", "card", "Projets/fusion-trois-apps#5", "Kanbante · fusion-trois-apps #5")),
])
def test_parse_formes_reconnues(url, attendu):
assert parse_deep_link(url) == attendu
@pytest.mark.parametrize("url", [
"https://example.com/?task=1",
"https://og.dracodev.net/#file=sansdeuxpoints",
"https://og.dracodev.net/",
"https://flowdeck.dracodev.net/autre/12",
"https://git.dracodev.net/Projets/fusion-trois-apps",
"pas une url",
"",
])
def test_parse_formes_refusees(url):
assert parse_deep_link(url) is None
# ------------------------------------------------------------ stockage
def test_stockage_cycle(refs_env):
ref = add_ref("alice", "TestVault", "Note A.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["owner_type"] == "obsigate.note"
assert ref["owner_id"] == "TestVault:Note A.md"
assert get_ref("alice", ref["id"])["target_id"] == "42"
assert [r["id"] for r in list_refs("alice", "TestVault", "Note A.md")] == [ref["id"]]
assert list_refs("alice", "TestVault", "Autre.md") == []
assert list_refs("bob", "TestVault", "Note A.md") == [] # par utilisateur
removed = delete_ref("alice", ref["id"])
assert removed["id"] == ref["id"]
assert delete_ref("alice", ref["id"]) is None
assert list_refs("alice", "TestVault", "Note A.md") == []
# ------------------------------------------------------------ API
def test_api_cycle_complet(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://flowdeck.dracodev.net/collections/3",
})
assert r.status_code == 201, r.text
ref = r.json()
assert ref["target_app"] == "flowdeck"
assert ref["target_type"] == "project"
assert ref["label"] == "Flowdeck · projet 3"
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://tiktak.dracodev.net/?task=9", "label": "Ma tâche",
})
assert r.status_code == 201, r.text
assert r.json()["label"] == "Ma tâche"
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert r.status_code == 200
assert [x["target_app"] for x in r.json()["refs"]] == ["flowdeck", "tiktak"]
r = client.delete(f"/api/refs/{ref['id']}")
assert r.status_code == 200
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak"]
def test_api_url_inconnue_422(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://example.com/whatever",
})
assert r.status_code == 422
def test_api_delete_introuvable_404(client, refs_env):
r = client.delete("/api/refs/inexistant")
assert r.status_code == 404
def test_api_vault_sans_acces_403(admin_client, refs_env):
"""Auth activée : un utilisateur limité à TestVault reçoit 403 ailleurs."""
r = admin_client.post("/api/auth/login",
json={"username": "normaluser", "password": "normal123"})
assert r.status_code == 200, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "VaultFantome", "path": "x.md"})
assert r.status_code == 403
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "TestVault", "path": "x.md"})
assert r.status_code == 200