humhub: fetch-Befehl + space-list/resolve_container ins Plugin

- fetch <content-id|perma-url> [--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) <noreply@anthropic.com>
This commit is contained in:
sko
2026-08-05 06:03:41 +02:00
co-authored by Claude Opus 4.8
parent aa9888700d
commit 04886d73f5
2 changed files with 192 additions and 9 deletions
+27 -3
View File
@@ -19,6 +19,29 @@ mitgelieferte, self-contained `humhub.py`.
./humhub.py <befehl> ... ./humhub.py <befehl> ...
``` ```
### 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 <content-id|perma-url> # Inhalt + Herkunfts-Kopf auf stdout
./humhub.py fetch <content-id|perma-url> --save # ablegen unter resources/humhub-<id>-<slug>.md
./humhub.py fetch <content-id|perma-url> --save --out <dir>
```
`--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 ### Wiki
```bash ```bash
@@ -37,9 +60,10 @@ mitgelieferte, self-contained `humhub.py`.
``` ```
Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes
Shell-Argument (Sonderzeichen). `container_id` ist die Space-/Profil-Container-ID Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische
in HumHub (z.&nbsp;B. ein User- oder Space-Container). Mit `wiki-list` / Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
`post-list` die passende ID/Seite ermitteln. löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten
per `wiki-list` / `post-list`.
## Zugangsdaten ## Zugangsdaten
+165 -6
View File
@@ -11,8 +11,11 @@
import argparse import argparse
import json import json
import os import os
import re
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse
import requests import requests
from dotenv import load_dotenv, find_dotenv 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() 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]: def parse_topics(topics_str: str | None) -> list[dict]:
if not topics_str: if not topics_str:
return [] return []
@@ -126,6 +153,7 @@ def cmd_wiki_update(args: argparse.Namespace) -> None:
def cmd_wiki_create(args: argparse.Namespace) -> None: def cmd_wiki_create(args: argparse.Namespace) -> None:
session, _ = get_session() session, _ = get_session()
container_id = resolve_container(session, args.container_id)
content = read_file(args.file) if args.file else "" content = read_file(args.file) if args.file else ""
wiki_page: dict = {"title": args.title} wiki_page: dict = {"title": args.title}
@@ -140,7 +168,7 @@ def cmd_wiki_create(args: argparse.Namespace) -> None:
if topics: if topics:
payload["content"] = {"topics": 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") page_id = data.get("id")
url = data.get("content", {}).get("metadata", {}).get("url", "") url = data.get("content", {}).get("metadata", {}).get("url", "")
print(f"Erstellt: '{data.get('title')}' (ID {page_id})") 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: def cmd_post_list(args: argparse.Namespace) -> None:
session, _ = get_session() 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", []) results = data.get("results", [])
if not results: if not results:
print("(keine Posts)") print("(keine Posts)")
@@ -167,6 +196,7 @@ def cmd_post_list(args: argparse.Namespace) -> None:
def cmd_post_create(args: argparse.Namespace) -> None: def cmd_post_create(args: argparse.Namespace) -> None:
session, _ = get_session() session, _ = get_session()
container_id = resolve_container(session, args.container_id)
message = read_file(args.file) message = read_file(args.file)
topics = parse_topics(args.topics) topics = parse_topics(args.topics)
@@ -174,7 +204,7 @@ def cmd_post_create(args: argparse.Namespace) -> None:
if topics: if topics:
payload["data"]["content"] = {"topics": 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") pid = data.get("id")
saved_topics = [t["name"] for t in data.get("content", {}).get("topics", [])] saved_topics = [t["name"] for t in data.get("content", {}).get("topics", [])]
print(f"Post erstellt (ID {pid})") print(f"Post erstellt (ID {pid})")
@@ -199,12 +229,137 @@ def cmd_post_update(args: argparse.Namespace) -> None:
print(f"Topics: {', '.join(saved_topics)}") 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"
f"Quelle: {perma}\n"
f"Seite: {info['page_url']}\n"
f"Titel: {info['title']}\n"
f"Typ: {info['kind']}\n"
f"Topics: {', '.join(info['topics']) or '(keine)'}\n"
f"Stand: {info['updated_at']}\n"
f"Abgerufen: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
"-->\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 ───────────────────────────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────────────────────────
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="HumHub REST API CLI") parser = argparse.ArgumentParser(description="HumHub REST API CLI")
sub = parser.add_subparsers(dest="command", required=True) 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 # wiki-list
p = sub.add_parser("wiki-list", help="Alle Wiki-Seiten auflisten") p = sub.add_parser("wiki-list", help="Alle Wiki-Seiten auflisten")
p.add_argument("filter", nargs="?", help="Filter nach Titel (case-insensitiv)") 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.add_argument("--parent-id", type=int, default=None, dest="parent_id", help="ID der Oberseite")
p.set_defaults(func=cmd_wiki_update) 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 # wiki-create
p = sub.add_parser("wiki-create", help="Neue Wiki-Seite anlegen") 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("title", help="Seitentitel")
p.add_argument("file", nargs="?", default=None, help="Markdown-Datei oder '-' für stdin (optional)") 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") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
@@ -235,13 +394,13 @@ def main() -> None:
# post-list # post-list
p = sub.add_parser("post-list", help="Posts eines Containers auflisten") 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.add_argument("--limit", type=int, default=10, help="Max. Anzahl Posts (Standard: 10)")
p.set_defaults(func=cmd_post_list) p.set_defaults(func=cmd_post_list)
# post-create # post-create
p = sub.add_parser("post-create", help="Neuen Post erstellen") 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("file", help="Markdown-Datei oder '-' für stdin")
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen") p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
p.set_defaults(func=cmd_post_create) p.set_defaults(func=cmd_post_create)