173 lines
5.8 KiB
Python
173 lines
5.8 KiB
Python
"""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
|