diff --git a/CHANGELOG.md b/CHANGELOG.md index ce1f44c..b5a6d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.69.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.70.0**. --- @@ -14,6 +14,33 @@ 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é diff --git a/README.fr.md b/README.fr.md index 5bd3711..5da1533 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.69.0-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.69.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.70.0). --- -*Projet : ObsiGate | Version : 2.69.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.70.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 616b5e9..de032a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.69.0-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.69.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.70.0). --- -*Project: ObsiGate | Version: 2.69.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.70.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index a740b92..38a7743 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.69.0 +2.70.0 diff --git a/backend/external_refs.py b/backend/external_refs.py new file mode 100644 index 0000000..4b783e3 --- /dev/null +++ b/backend/external_refs.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 8f70710..790ca3e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py index 21def53..365e92e 100644 --- a/backend/openapi_docs.py +++ b/backend/openapi_docs.py @@ -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"), diff --git a/backend/routers/external_refs.py b/backend/routers/external_refs.py new file mode 100644 index 0000000..ee0e0e0 --- /dev/null +++ b/backend/routers/external_refs.py @@ -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 diff --git a/backend/schemas.py b/backend/schemas.py index 21bf660..c09df7f 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -619,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="':' 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é) # --------------------------------------------------------------------------- diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 579fa4b..6398076 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.69.0" +version = "2.70.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c093c0a..d9f2066 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.69.0" +version = "2.70.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index c124703..b7b68da 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.69.0", + "version": "2.70.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 53f25e4..f659ced 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.69.0 | **Dernière mise à jour :** 2026-10-15 +> **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 diff --git a/docs/features/fusion-bloc1-barre-refs-215.md b/docs/features/fusion-bloc1-barre-refs-215.md new file mode 100644 index 0000000..cb4a4bb --- /dev/null +++ b/docs/features/fusion-bloc1-barre-refs-215.md @@ -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 = ":"`). +- **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: ` 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=`), +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). diff --git a/frontend/app.js b/frontend/app.js index 6a51793..4fae22d 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 }, + })); } // --------------------------------------------------------------------------- diff --git a/frontend/draco-barre.css b/frontend/draco-barre.css new file mode 100644 index 0000000..02b1488 --- /dev/null +++ b/frontend/draco-barre.css @@ -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 + 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; } +} diff --git a/frontend/draco-barre.js b/frontend/draco-barre.js new file mode 100644 index 0000000..b39ba50 --- /dev/null +++ b/frontend/draco-barre.js @@ -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); +})(); diff --git a/frontend/draco-liens.js b/frontend/draco-liens.js new file mode 100644 index 0000000..47e60f7 --- /dev/null +++ b/frontend/draco-liens.js @@ -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=) ; + * - 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); + }); +})(); diff --git a/frontend/index.html b/frontend/index.html index 8ebb618..3089515 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -75,6 +75,7 @@ @media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } } + - + + +
+ +
+ +
+
+
+ Domaine + + + +
+ + + + ✦ +
+
+ +
@@ -5931,5 +6040,7 @@ curl -X POST https://votre-serveur.com/webhook \
+ + diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 06e0b0a..29d5db4 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -752,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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 3155024..eb43f0a 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -752,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", diff --git a/frontend/style.css b/frontend/style.css index 5b6d1f8..1058277 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; } diff --git a/package.json b/package.json index ef7219e..2489b74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.69.0", + "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": { diff --git a/tests/test_external_refs.py b/tests/test_external_refs.py new file mode 100644 index 0000000..1dc62c8 --- /dev/null +++ b/tests/test_external_refs.py @@ -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