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
|
||||
Reference in New Issue
Block a user