feat: barre d'apps commune et références croisées des notes (#215)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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="'<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é)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user