Files
rtls 1b4d20f295 Dateien nach "app" hochladen
Anpassungen an der Ausgabe in Outline
2026-07-24 16:31:32 +00:00

188 lines
6.8 KiB
Python

"""Kleiner Client für die Outline API.
Doku: https://www.getoutline.com/developers
"""
from __future__ import annotations
import httpx
from .config import settings
class OutlineClient:
def __init__(self) -> None:
self._base = f"{settings.outline_base_url.rstrip('/')}/api"
self._client = httpx.Client(
verify=settings.outline_verify_ssl,
timeout=30.0,
headers={
"Authorization": f"Bearer {settings.outline_api_token}",
"Content-Type": "application/json",
},
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "OutlineClient":
return self
def __exit__(self, *exc) -> None:
self.close()
def _post(self, path: str, json: dict) -> dict:
resp = self._client.post(f"{self._base}{path}", json=json)
resp.raise_for_status()
data = resp.json()
if "data" not in data:
raise RuntimeError(f"Unerwartete Outline-Antwort von {path}: {data}")
return data
def resolve_collection_id(self, collection_id_or_slug: str) -> str:
"""Löst eine Collection-ID oder Friendly-URL-Slug in die echte UUID auf.
`documents.search`/`documents.create` akzeptieren für `collectionId`
nur eine vollständige UUID. Die ID in der Browser-Adresszeile ist bei
neueren Outline-Versionen aber die gekürzte Friendly-URL-ID, keine
UUID. `collections.info` akzeptiert beides, daher lösen wir hier
einmal auf.
"""
data = self._post("/collections.info", {"id": collection_id_or_slug})
return data["data"]["id"]
def get_document(self, document_id_or_slug: str) -> dict:
"""Lädt die vollständigen Metadaten eines Dokuments per ID oder
Friendly-URL-Slug (u.a. für den aktuellen Titel gebraucht)."""
data = self._post("/documents.info", {"id": document_id_or_slug})
return data["data"]
def resolve_document_id(self, document_id_or_slug: str) -> str:
"""Löst eine Dokument-ID oder Friendly-URL-Slug in die echte UUID auf."""
return self.get_document(document_id_or_slug)["id"]
def find_document_by_title(
self, title: str, collection_id: str, parent_document_id: str | None = None
) -> dict | None:
"""Sucht ein bestehendes Dokument mit exakt diesem Titel in der Collection.
Ist parent_document_id gesetzt, wird zusätzlich geprüft, dass der Treffer
auch wirklich unter dieser Elternseite hängt (z.B. "Netze"), damit gleich
benannte Dokumente an anderer Stelle in der Collection nicht versehentlich
überschrieben werden.
"""
data = self._post(
"/documents.search",
{"query": title, "collectionId": collection_id, "limit": 25},
)
for hit in data["data"]:
doc = hit.get("document", hit)
if doc.get("title") != title:
continue
if parent_document_id and doc.get("parentDocumentId") != parent_document_id:
continue
return doc
return None
def create_document(
self, title: str, text: str, collection_id: str, parent_document_id: str | None = None
) -> dict:
payload = {
"title": title,
"text": text,
"collectionId": collection_id,
"publish": True,
}
if parent_document_id:
payload["parentDocumentId"] = parent_document_id
data = self._post("/documents.create", payload)
return data["data"]
def update_document(self, document_id: str, title: str, text: str) -> dict:
data = self._post(
"/documents.update",
{"id": document_id, "title": title, "text": text},
)
return data["data"]
def list_documents(self, collection_id: str, parent_document_id: str | None = None) -> list[dict]:
"""Listet alle Dokumente einer Collection (optional gefiltert auf
direkte Kinder von parent_document_id), paginiert."""
docs: list[dict] = []
offset = 0
limit = 100
while True:
payload: dict = {"collectionId": collection_id, "limit": limit, "offset": offset}
if parent_document_id:
payload["parentDocumentId"] = parent_document_id
data = self._post("/documents.list", payload)
batch = data["data"]
docs.extend(batch)
if len(batch) < limit:
break
offset += limit
return docs
def list_revisions(self, document_id: str) -> list[dict]:
"""Listet alle Revisionen (Versionsverlauf) eines Dokuments, paginiert."""
revisions: list[dict] = []
offset = 0
limit = 100
while True:
data = self._post(
"/revisions.list",
{"documentId": document_id, "limit": limit, "offset": offset},
)
batch = data["data"]
revisions.extend(batch)
if len(batch) < limit:
break
offset += limit
return revisions
def delete_revision(self, revision_id: str) -> None:
self._post("/revisions.delete", {"id": revision_id})
def purge_document_history(self, document_id: str) -> int:
"""Löscht ALLE Revisionen eines Dokuments dauerhaft und unwiederbringlich
(nicht der aktuelle Inhalt selbst, nur der Versionsverlauf).
Returns: Anzahl gelöschter Revisionen.
"""
revisions = self.list_revisions(document_id)
for revision in revisions:
self.delete_revision(revision["id"])
return len(revisions)
def prune_document_history(self, document_id: str, keep: int) -> int:
"""Behält nur die `keep` neuesten Revisionen eines Dokuments, alle
älteren werden dauerhaft gelöscht und sind nicht wiederherstellbar.
Returns: Anzahl gelöschter Revisionen.
"""
if keep < 0:
keep = 0
revisions = self.list_revisions(document_id)
revisions.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
to_delete = revisions[keep:]
for revision in to_delete:
self.delete_revision(revision["id"])
return len(to_delete)
def upsert_document(
self,
title: str,
text: str,
collection_id: str,
parent_document_id: str | None = None,
) -> tuple[dict, bool]:
"""Legt ein Dokument an oder aktualisiert es, falls der Titel bereits existiert.
Returns: (document, created)
"""
existing = self.find_document_by_title(title, collection_id, parent_document_id)
if existing:
doc = self.update_document(existing["id"], title, text)
return doc, False
doc = self.create_document(title, text, collection_id, parent_document_id)
return doc, True