From 04886d73f51444504073ba8c41d02337169f1fde Mon Sep 17 00:00:00 2001 From: Serge Koenigsmann Date: Wed, 5 Aug 2026 06:03:41 +0200 Subject: [PATCH] humhub: fetch-Befehl + space-list/resolve_container ins Plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch [--save]: löst Perma-Links selbst auf - space-list + resolve_container: container_id akzeptiert Slug/URL/ID - SKILL.md entsprechend ergänzt Gleicht das Plugin an die kanonische humhub.py (dokumentierer) an. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/humhub/skills/humhub/SKILL.md | 30 ++++- plugins/humhub/skills/humhub/humhub.py | 171 ++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 9 deletions(-) diff --git a/plugins/humhub/skills/humhub/SKILL.md b/plugins/humhub/skills/humhub/SKILL.md index 6aee06e..b6ff490 100644 --- a/plugins/humhub/skills/humhub/SKILL.md +++ b/plugins/humhub/skills/humhub/SKILL.md @@ -19,6 +19,29 @@ mitgelieferte, self-contained `humhub.py`. ./humhub.py ... ``` +### Perma-Link / Content-ID holen (empfohlener Einstieg) + +HumHub-Links der Form `…/content/perma?id=NNNN` tragen eine **Content-ID** — nicht +die Wiki-Seiten- oder Post-ID, die die anderen Befehle erwarten. `fetch` löst das +selbst auf (erkennt WikiPage vs. Post) und braucht keine Vorab-Recherche: + +```bash +./humhub.py fetch # Inhalt + Herkunfts-Kopf auf stdout +./humhub.py fetch --save # ablegen unter resources/humhub--.md +./humhub.py fetch --save --out +``` + +`--save` schreibt Markdown mit einem Herkunfts-Kopf (Quelle, Titel, Typ, Topics, +Stand, Abrufdatum). Zielverzeichnis ist standardmäßig `resources/` im aktuellen +Projekt — dieses gehört üblicherweise in `.gitignore` (abgerufene Fremdinhalte +werden nicht committet). + +### Spaces + +```bash +./humhub.py space-list # Spaces auflisten mit contentcontainer_id +``` + ### Wiki ```bash @@ -37,9 +60,10 @@ mitgelieferte, self-contained `humhub.py`. ``` Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes -Shell-Argument (Sonderzeichen). `container_id` ist die Space-/Profil-Container-ID -in HumHub (z. B. ein User- oder Space-Container). Mit `wiki-list` / -`post-list` die passende ID/Seite ermitteln. +Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische +Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container` +löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten +per `wiki-list` / `post-list`. ## Zugangsdaten diff --git a/plugins/humhub/skills/humhub/humhub.py b/plugins/humhub/skills/humhub/humhub.py index 1cecddf..8219c79 100755 --- a/plugins/humhub/skills/humhub/humhub.py +++ b/plugins/humhub/skills/humhub/humhub.py @@ -11,8 +11,11 @@ import argparse import json import os +import re import sys +from datetime import datetime from pathlib import Path +from urllib.parse import parse_qs, urlparse import requests from dotenv import load_dotenv, find_dotenv @@ -52,6 +55,30 @@ def api_post(session: requests.Session, path: str, payload: dict) -> dict: return r.json() +def resolve_container(session: requests.Session, value: str) -> int: + """Resolve a container ID, space slug, or space URL to a contentcontainer_id.""" + try: + return int(value) + except ValueError: + pass + # Strip full URL down to slug + slug = value.rstrip("/").split("/s/")[-1].rstrip("/") if "/s/" in value else value.strip("/") + page = 1 + while True: + data = api_get(session, f"/space?limit=50&page={page}") + results = data.get("results", []) + if not results: + break + for space in results: + space_slug = space.get("url", "").rstrip("/").split("/s/")[-1].rstrip("/") + if space_slug == slug or space.get("name", "").lower() == slug.lower(): + return space["contentcontainer_id"] + if len(results) < 50: + break + page += 1 + sys.exit(f"Error: Space '{value}' nicht gefunden. Verfügbare Spaces: uv run humhub.py space-list") + + def parse_topics(topics_str: str | None) -> list[dict]: if not topics_str: return [] @@ -126,6 +153,7 @@ def cmd_wiki_update(args: argparse.Namespace) -> None: def cmd_wiki_create(args: argparse.Namespace) -> None: session, _ = get_session() + container_id = resolve_container(session, args.container_id) content = read_file(args.file) if args.file else "" wiki_page: dict = {"title": args.title} @@ -140,7 +168,7 @@ def cmd_wiki_create(args: argparse.Namespace) -> None: if topics: payload["content"] = {"topics": topics} - data = api_post(session, f"/wiki/container/{args.container_id}", payload) + data = api_post(session, f"/wiki/container/{container_id}", payload) page_id = data.get("id") url = data.get("content", {}).get("metadata", {}).get("url", "") print(f"Erstellt: '{data.get('title')}' (ID {page_id})") @@ -152,7 +180,8 @@ def cmd_wiki_create(args: argparse.Namespace) -> None: def cmd_post_list(args: argparse.Namespace) -> None: session, _ = get_session() - data = api_get(session, f"/post/container/{args.container_id}?limit={args.limit}") + container_id = resolve_container(session, args.container_id) + data = api_get(session, f"/post/container/{container_id}?limit={args.limit}") results = data.get("results", []) if not results: print("(keine Posts)") @@ -167,6 +196,7 @@ def cmd_post_list(args: argparse.Namespace) -> None: def cmd_post_create(args: argparse.Namespace) -> None: session, _ = get_session() + container_id = resolve_container(session, args.container_id) message = read_file(args.file) topics = parse_topics(args.topics) @@ -174,7 +204,7 @@ def cmd_post_create(args: argparse.Namespace) -> None: if topics: payload["data"]["content"] = {"topics": topics} - data = api_post(session, f"/post/container/{args.container_id}", payload) + data = api_post(session, f"/post/container/{container_id}", payload) pid = data.get("id") saved_topics = [t["name"] for t in data.get("content", {}).get("topics", [])] print(f"Post erstellt (ID {pid})") @@ -199,12 +229,137 @@ def cmd_post_update(args: argparse.Namespace) -> None: print(f"Topics: {', '.join(saved_topics)}") +def cmd_space_list(args: argparse.Namespace) -> None: + session, _ = get_session() + page = 1 + found = False + while True: + data = api_get(session, f"/space?limit=50&page={page}") + results = data.get("results", []) + if not results: + break + for space in results: + slug = space.get("url", "").rstrip("/").split("/s/")[-1].rstrip("/") + ccid = space.get("contentcontainer_id", "?") + name = space.get("name", "") + print(f"ccid={ccid:>4} slug={slug:<30} {name}") + found = True + if len(results) < 50: + break + page += 1 + if not found: + print("(keine Spaces gefunden)") + + +# ── Fetch (perma-/content-id-agnostisch) ───────────────────────────────────── + +def parse_content_ref(ref: str) -> int: + """Akzeptiert eine blanke Content-ID oder einen Perma-Link + (…/content/perma?id=NNNN) und liefert die Content-ID.""" + ref = ref.strip() + if ref.isdigit(): + return int(ref) + qs = parse_qs(urlparse(ref).query) + if "id" in qs and qs["id"][0].isdigit(): + return int(qs["id"][0]) + sys.exit(f"Error: keine Content-ID erkennbar in '{ref}'") + + +def slugify(text: str) -> str: + text = text.lower() + for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + text = text.replace(a, b) + text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") + return text[:60] or "inhalt" + + +def resolve_content(session: requests.Session, content_id: int) -> dict: + """Löst eine Content-ID auf den zugrundeliegenden Datensatz auf + (WikiPage oder Post) und liefert Titel, Inhalt und Metadaten.""" + envelope = api_get(session, f"/content/{content_id}") + meta = envelope.get("metadata", {}) + model = meta.get("object_model", "") + object_id = meta.get("object_id") + page_url = meta.get("url", "") + topics = [t["name"] for t in envelope.get("topics", [])] + updated_at = meta.get("updated_at", "") + + if model.endswith("WikiPage"): + data = api_get(session, f"/wiki/page/{object_id}") + title = data.get("title", "") or f"Wiki-Seite {object_id}" + content = data.get("latest_revision", {}).get("content", "") + kind = f"WikiPage (page {object_id})" + seg = page_url.rstrip("/").split("/")[-1] + slug = seg if seg and not seg.isdigit() else slugify(title) + elif model.endswith("Post"): + data = api_get(session, f"/post/{object_id}") + content = data.get("data", {}).get("message") or data.get("message", "") + first = content.splitlines()[0].lstrip("# ").strip() if content else "" + title = first or f"Post {object_id}" + kind = f"Post (id {object_id})" + slug = slugify(title) + else: + sys.exit(f"Error: Objekttyp nicht unterstützt: {model or '(unbekannt)'}") + + return { + "content_id": content_id, + "title": title, + "content": content, + "kind": kind, + "topics": topics, + "updated_at": updated_at, + "page_url": f"https://humhub.inmedias.it{page_url}" if page_url else "", + "slug": slug, + } + + +def render_resource(info: dict) -> str: + """Inhalt mit Herkunfts-Kopf als Markdown-Kommentar.""" + perma = f"https://humhub.inmedias.it/content/perma?id={info['content_id']}" + header = ( + "\n\n" + ) + return header + info["content"].rstrip() + "\n" + + +def cmd_fetch(args: argparse.Namespace) -> None: + session, _ = get_session() + content_id = parse_content_ref(args.ref) + info = resolve_content(session, content_id) + body = render_resource(info) + + if args.save: + outdir = Path(args.out) + outdir.mkdir(parents=True, exist_ok=True) + path = outdir / f"humhub-{content_id}-{info['slug']}.md" + path.write_text(body, encoding="utf-8") + print(f"Gespeichert: {path}") + print(f" {info['kind']} — '{info['title']}' ({len(info['content'])} Zeichen)") + else: + sys.stdout.write(body) + + # ── Main ───────────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser(description="HumHub REST API CLI") sub = parser.add_subparsers(dest="command", required=True) + # fetch + p = sub.add_parser("fetch", help="Content-ID/Perma-Link auflösen und Inhalt holen (WikiPage oder Post)") + p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)") + p.add_argument("--save", action="store_true", help="In Datei ablegen statt auf stdout") + p.add_argument("--out", default="resources", help="Zielverzeichnis für --save (Standard: resources/)") + p.set_defaults(func=cmd_fetch) + # wiki-list p = sub.add_parser("wiki-list", help="Alle Wiki-Seiten auflisten") p.add_argument("filter", nargs="?", help="Filter nach Titel (case-insensitiv)") @@ -224,9 +379,13 @@ def main() -> None: p.add_argument("--parent-id", type=int, default=None, dest="parent_id", help="ID der Oberseite") p.set_defaults(func=cmd_wiki_update) + # space-list + p = sub.add_parser("space-list", help="Spaces auflisten mit contentcontainer_id") + p.set_defaults(func=cmd_space_list) + # wiki-create p = sub.add_parser("wiki-create", help="Neue Wiki-Seite anlegen") - p.add_argument("container_id", type=int, help="Container-ID (z.B. 37 für user serge)") + p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID") p.add_argument("title", help="Seitentitel") p.add_argument("file", nargs="?", default=None, help="Markdown-Datei oder '-' für stdin (optional)") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen") @@ -235,13 +394,13 @@ def main() -> None: # post-list p = sub.add_parser("post-list", help="Posts eines Containers auflisten") - p.add_argument("container_id", type=int, help="Container-ID") + p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID") p.add_argument("--limit", type=int, default=10, help="Max. Anzahl Posts (Standard: 10)") p.set_defaults(func=cmd_post_list) # post-create p = sub.add_parser("post-create", help="Neuen Post erstellen") - p.add_argument("container_id", type=int, help="Container-ID") + p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID") p.add_argument("file", help="Markdown-Datei oder '-' für stdin") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen") p.set_defaults(func=cmd_post_create)