Compare commits
11
Commits
ab56ba0c64
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4675ebce6a | ||
|
|
e1ed8688f0 | ||
|
|
d00d195ac3 | ||
|
|
a8688302ac | ||
|
|
2e19639e12 | ||
|
|
72a1ca284a | ||
|
|
847f8ab3f4 | ||
|
|
10ea9277c9 | ||
|
|
1c9f95a11f | ||
|
|
57c1910734 | ||
|
|
7efb302c10 |
No files matched your search
@@ -36,7 +36,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redmine",
|
"name": "redmine",
|
||||||
"source": "./plugins/redmine",
|
"source": "./plugins/redmine",
|
||||||
"description": "Redmine via REST-API: Projekte, Tickets, Versionen und News auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)."
|
"description": "Redmine via REST-API: Projekte, Tickets, Versionen, News und Projekt-Wikis auflisten, anzeigen, anlegen, aktualisieren und kommentieren via redmine.py (API-Key lokal)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "grill-me",
|
"name": "grill-me",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "humhub",
|
"name": "humhub",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
|
"description": "HumHub via REST-API: Wiki-Seiten, Posts, Umfragen und Kommentare auflisten, anzeigen, anlegen und aktualisieren (humhub.py).",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "inmedias.it"
|
"name": "inmedias.it"
|
||||||
|
|||||||
@@ -94,18 +94,50 @@ und Wiki-Seiten.
|
|||||||
### Direktnachrichten
|
### Direktnachrichten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-]
|
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] # Probelauf
|
||||||
|
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-] --send # senden
|
||||||
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
|
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
|
||||||
./humhub.py mail-send jan.stobbe,26 "Betreff" -
|
./humhub.py mail-send jan.stobbe,26 "Betreff" - --send
|
||||||
./humhub.py mail-reply <konversations-id> [datei|-]
|
./humhub.py mail-reply <konversations-id> [datei|-]
|
||||||
|
./humhub.py mail-user-remove <konversations-id> <empfaenger>
|
||||||
```
|
```
|
||||||
|
|
||||||
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
|
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
|
||||||
|
**Ohne `--send` wird nichts gesendet**, sondern nur aufgelöst und angezeigt,
|
||||||
|
wer die Nachricht bekäme — der Probelauf ist der Default.
|
||||||
|
|
||||||
|
**`recipient` will GUIDs, keine User-IDs.** `mail-send` löst jede Angabe
|
||||||
|
deshalb bis zur GUID auf (`/user/<id>` liefert sie, die Benutzerliste je nach
|
||||||
|
Fassung nicht). Mit numerischen IDs antwortet die API `400 Validation failed`
|
||||||
|
und „Du kannst keine Nachricht ohne Empfänger senden!" — die Meldung klingt
|
||||||
|
nach einem leeren Feld, obwohl das Feld gefüllt ist, und weist damit in die
|
||||||
|
falsche Richtung.
|
||||||
|
|
||||||
`mail-reply` hängt einen Beitrag an eine bestehende Konversation
|
`mail-reply` hängt einen Beitrag an eine bestehende Konversation
|
||||||
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
|
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
|
||||||
`…/mail/mail/index?id=35`.
|
`…/mail/mail/index?id=35`.
|
||||||
|
|
||||||
|
**Eine Fehlzustellung lässt sich nicht löschen.** `DELETE /mail/<id>` gibt es
|
||||||
|
in dieser API-Version nicht (404, Route unbekannt), und über die Oberfläche
|
||||||
|
kommt man auch nicht dran. Das einzige Gegenmittel ist
|
||||||
|
`mail-user-remove`, das einen Teilnehmer wieder aus der Konversation nimmt
|
||||||
|
(`DELETE /mail/<id>/user/<userid>`). Ob der Empfänger vorher schon eine
|
||||||
|
Benachrichtigung bekommen hat, bleibt dabei offen.
|
||||||
|
|
||||||
|
**Deshalb prüft `mail-send` nach dem Versand die Teilnehmerliste.** Eine
|
||||||
|
falsche Kennung in `recipient` führt nicht verlässlich zu einer
|
||||||
|
Fehlermeldung: am 25.09.2026 hat die API einen Versand mit numerischen IDs
|
||||||
|
mit `HTTP 200` quittiert und die Nachricht an einen **anderen** Benutzer
|
||||||
|
zugestellt. Der Befehl gibt deshalb aus, wer die Konversation tatsächlich
|
||||||
|
bekommen hat, und nennt bei einer Abweichung das `mail-user-remove`-Kommando.
|
||||||
|
Die Teilnehmerliste kann direkt nach dem Anlegen unvollständig sein — im
|
||||||
|
Zweifel noch einmal nachsehen, bevor man daraus einen Schluss zieht.
|
||||||
|
|
||||||
|
Zwei Routen, zwei Kennungen: `POST /mail` will die **GUID**,
|
||||||
|
`DELETE /mail/<id>/user/<userid>` die **numerische ID**. Beides ist im
|
||||||
|
Skript gekapselt, `mail-user-remove` nimmt wie alle Befehle Benutzername,
|
||||||
|
Profil-URL oder ID.
|
||||||
|
|
||||||
**Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
|
**Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
|
||||||
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
|
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
|
||||||
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst
|
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst
|
||||||
@@ -123,6 +155,14 @@ Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
|
|||||||
löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten
|
löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten
|
||||||
per `wiki-list` / `post-list`.
|
per `wiki-list` / `post-list`.
|
||||||
|
|
||||||
|
**HumHub macht aus Zeichenfolgen wie `:)` Emojis — auch mitten im Fachtext.**
|
||||||
|
Am 08.10.2026 wurde aus „(0.0.0.0 und ::)“ ein „(0.0.0.0 und :😃“: der zweite
|
||||||
|
Doppelpunkt der IPv6-Adresse `::` bildete mit der schließenden Klammer ein
|
||||||
|
Smiley. Betroffen sind typisch IPv6-Adressen (`::`, `::1`, `[::]:3306`),
|
||||||
|
Portangaben und Pfade direkt vor `)` oder `(`. **Solche Werte immer in
|
||||||
|
Backticks setzen** — in Inline-Code ersetzt HumHub nichts. Vor dem Absenden
|
||||||
|
den Text außerhalb von Backticks auf `:)`, `:(`, `;)`, `:D`, `:P` prüfen.
|
||||||
|
|
||||||
## Zugangsdaten
|
## Zugangsdaten
|
||||||
|
|
||||||
`humhub.py` bringt **keinen** Token mit. Jede/r hinterlegt ein eigenes
|
`humhub.py` bringt **keinen** Token mit. Jede/r hinterlegt ein eigenes
|
||||||
|
|||||||
@@ -350,8 +350,20 @@ def cmd_comment_create(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
# ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
|
# ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def resolve_user(session: requests.Session, value: str) -> int:
|
def user_guid(session: requests.Session, user: dict) -> str:
|
||||||
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user ID.
|
"""GUID eines Benutzers, notfalls über /user/<id> nachgeladen.
|
||||||
|
|
||||||
|
Die Benutzerliste liefert die GUID je nach Fassung nicht mit.
|
||||||
|
"""
|
||||||
|
guid = user.get("guid") or (user.get("account") or {}).get("guid")
|
||||||
|
if guid:
|
||||||
|
return str(guid)
|
||||||
|
uid = user.get("id") or (user.get("account") or {}).get("id")
|
||||||
|
return str(api_get(session, f"/user/{uid}")["guid"])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user_info(session: requests.Session, value: str) -> dict:
|
||||||
|
"""Benutzerdatensatz zu numerischer ID, Benutzername oder Profil-URL.
|
||||||
|
|
||||||
Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
|
Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
|
||||||
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und
|
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und
|
||||||
@@ -360,7 +372,7 @@ def resolve_user(session: requests.Session, value: str) -> int:
|
|||||||
"""
|
"""
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if value.isdigit():
|
if value.isdigit():
|
||||||
return int(value)
|
return api_get(session, f"/user/{int(value)}")
|
||||||
name = value
|
name = value
|
||||||
if "/" in value:
|
if "/" in value:
|
||||||
teile = [t for t in urlparse(value).path.split("/") if t]
|
teile = [t for t in urlparse(value).path.split("/") if t]
|
||||||
@@ -370,7 +382,7 @@ def resolve_user(session: requests.Session, value: str) -> int:
|
|||||||
r = session.get(f"{BASE_URL}/user/get-by-username",
|
r = session.get(f"{BASE_URL}/user/get-by-username",
|
||||||
params={"username": name})
|
params={"username": name})
|
||||||
if r.ok:
|
if r.ok:
|
||||||
return int(r.json()["id"])
|
return r.json()
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
while True:
|
while True:
|
||||||
@@ -381,26 +393,107 @@ def resolve_user(session: requests.Session, value: str) -> int:
|
|||||||
slug_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
|
slug_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
|
||||||
slug = slug_teile[-1] if slug_teile else ""
|
slug = slug_teile[-1] if slug_teile else ""
|
||||||
if name.lower() in (username.lower(), slug.lower()):
|
if name.lower() in (username.lower(), slug.lower()):
|
||||||
return int(u["id"])
|
return u
|
||||||
if len(results) < 50:
|
if len(results) < 50:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
|
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user(session: requests.Session, value: str) -> str:
|
||||||
|
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user GUID.
|
||||||
|
|
||||||
|
**GUID, nicht ID.** `POST /mail` nimmt als `recipient` ausschließlich
|
||||||
|
GUIDs an; mit numerischen IDs antwortet die API `400 Validation failed`
|
||||||
|
und „Du kannst keine Nachricht ohne Empfänger senden!" — eine Meldung,
|
||||||
|
die nach einem leeren Feld klingt, obwohl das Feld gefüllt ist.
|
||||||
|
"""
|
||||||
|
return user_guid(session, resolve_user_info(session, value))
|
||||||
|
|
||||||
|
|
||||||
|
def mail_teilnehmer(session: requests.Session, conv_id: int) -> list[dict]:
|
||||||
|
"""Teilnehmer einer Konversation. Leere Liste, wenn nicht lesbar."""
|
||||||
|
r = session.get(f"{BASE_URL}/mail/{conv_id}/users")
|
||||||
|
if not r.ok:
|
||||||
|
return []
|
||||||
|
daten = r.json()
|
||||||
|
return daten if isinstance(daten, list) else daten.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
|
def eigene_guid(session: requests.Session) -> str:
|
||||||
|
"""GUID des eigenen Kontos, leer wenn nicht ermittelbar."""
|
||||||
|
r = session.get(f"{BASE_URL}/user/me")
|
||||||
|
return str(r.json().get("guid", "")) if r.ok else ""
|
||||||
|
|
||||||
|
|
||||||
def cmd_mail_send(args: argparse.Namespace) -> None:
|
def cmd_mail_send(args: argparse.Namespace) -> None:
|
||||||
session, _ = get_session()
|
session, _ = get_session()
|
||||||
ids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()]
|
leute = [resolve_user_info(session, teil)
|
||||||
if not ids:
|
for teil in args.to.split(",") if teil.strip()]
|
||||||
|
if not leute:
|
||||||
sys.exit("Error: kein Empfänger angegeben")
|
sys.exit("Error: kein Empfänger angegeben")
|
||||||
|
guids = [user_guid(session, u) for u in leute]
|
||||||
|
namen = {g: u.get("display_name", "?") for g, u in zip(guids, leute)}
|
||||||
|
for g in guids:
|
||||||
|
print(f"Empfänger: {namen[g]} ({g})")
|
||||||
|
if not args.send:
|
||||||
|
print("PROBELAUF — nichts gesendet. Mit --send abschicken.")
|
||||||
|
return
|
||||||
|
|
||||||
message = read_file(args.file)
|
message = read_file(args.file)
|
||||||
data = api_post(session, "/mail", {
|
data = api_post(session, "/mail", {
|
||||||
"title": args.title,
|
"title": args.title,
|
||||||
"message": message,
|
"message": message,
|
||||||
"recipient": ids,
|
"recipient": guids,
|
||||||
})
|
})
|
||||||
empfaenger = ", ".join(str(i) for i in ids)
|
conv_id = data.get("id")
|
||||||
print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}")
|
print(f"Nachricht gesendet (Konversation {conv_id})")
|
||||||
|
|
||||||
|
# Gegenprobe. Am 25.09.2026 hat die API eine Nachricht mit numerischen IDs
|
||||||
|
# in `recipient` mit HTTP 200 quittiert und sie an einen *anderen*
|
||||||
|
# Benutzer zugestellt — sie meldet einen falschen Empfaenger also nicht
|
||||||
|
# zwingend als Fehler. Deshalb nach jedem Versand nachsehen, wer die
|
||||||
|
# Konversation tatsaechlich bekommen hat. Loeschen laesst sie sich nicht
|
||||||
|
# (`DELETE /mail/<id>` gibt es nicht), nur Teilnehmer entfernen.
|
||||||
|
teilnehmer = mail_teilnehmer(session, conv_id)
|
||||||
|
if not teilnehmer:
|
||||||
|
print("ACHTUNG: Teilnehmerliste nicht lesbar — Zustellung nicht geprüft.")
|
||||||
|
return
|
||||||
|
print("Teilnehmer laut Server:")
|
||||||
|
for u in teilnehmer:
|
||||||
|
print(f" {u.get('display_name')} ({u.get('guid')})")
|
||||||
|
drin = {str(u.get("guid")) for u in teilnehmer}
|
||||||
|
ich = eigene_guid(session)
|
||||||
|
fehlend = [g for g in guids if g not in drin]
|
||||||
|
fremd = [u for u in teilnehmer
|
||||||
|
if str(u.get("guid")) not in guids and str(u.get("guid")) != ich]
|
||||||
|
if fehlend:
|
||||||
|
print("ACHTUNG: nicht zugestellt an " +
|
||||||
|
", ".join(f"{namen[g]} ({g})" for g in fehlend))
|
||||||
|
if fremd:
|
||||||
|
print("ACHTUNG: unerwartete Teilnehmer — entfernen mit:")
|
||||||
|
for u in fremd:
|
||||||
|
print(f" ./humhub.py mail-user-remove {conv_id} {u.get('id')}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mail_user_remove(args: argparse.Namespace) -> None:
|
||||||
|
"""Nimmt einen Teilnehmer aus einer Konversation.
|
||||||
|
|
||||||
|
Der einzige Weg, eine Fehlzustellung zurueckzunehmen: die Konversation
|
||||||
|
selbst laesst sich weder ueber die API noch ueber die Oberflaeche
|
||||||
|
loeschen. Anders als beim Versand will diese Route die **numerische ID**,
|
||||||
|
nicht die GUID.
|
||||||
|
"""
|
||||||
|
session, _ = get_session()
|
||||||
|
user = resolve_user_info(session, args.user)
|
||||||
|
uid = user.get("id")
|
||||||
|
r = session.delete(f"{BASE_URL}/mail/{args.id}/user/{uid}")
|
||||||
|
if not r.ok:
|
||||||
|
sys.exit(f"Error {r.status_code}: {r.text[:200]}")
|
||||||
|
print(f"{user.get('display_name')} (ID {uid}) aus Konversation "
|
||||||
|
f"{args.id} entfernt. Verbleibend:")
|
||||||
|
for u in mail_teilnehmer(session, args.id):
|
||||||
|
print(f" {u.get('display_name')} ({u.get('guid')})")
|
||||||
|
|
||||||
|
|
||||||
def cmd_mail_reply(args: argparse.Namespace) -> None:
|
def cmd_mail_reply(args: argparse.Namespace) -> None:
|
||||||
@@ -588,8 +681,17 @@ def main() -> None:
|
|||||||
p.add_argument("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
|
p.add_argument("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
|
||||||
p.add_argument("title", help="Betreff")
|
p.add_argument("title", help="Betreff")
|
||||||
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("--send", action="store_true",
|
||||||
|
help="wirklich senden (ohne: Probelauf mit Empfängernamen)")
|
||||||
p.set_defaults(func=cmd_mail_send)
|
p.set_defaults(func=cmd_mail_send)
|
||||||
|
|
||||||
|
# mail-user-remove
|
||||||
|
p = sub.add_parser("mail-user-remove",
|
||||||
|
help="Teilnehmer aus einer Konversation entfernen (Fehlzustellung)")
|
||||||
|
p.add_argument("id", type=int, help="Konversations-ID")
|
||||||
|
p.add_argument("user", help="Benutzername, Profil-URL oder ID")
|
||||||
|
p.set_defaults(func=cmd_mail_user_remove)
|
||||||
|
|
||||||
# mail-reply
|
# mail-reply
|
||||||
p = sub.add_parser("mail-reply", help="In einer bestehenden Konversation antworten")
|
p = sub.add_parser("mail-reply", help="In einer bestehenden Konversation antworten")
|
||||||
p.add_argument("id", type=int, help="Konversations-ID (aus /mail/mail/index?id=NN)")
|
p.add_argument("id", type=int, help="Konversations-ID (aus /mail/mail/index?id=NN)")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mediawiki",
|
"name": "mediawiki",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
|
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "inmedias.it"
|
"name": "inmedias.it"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: mediawiki
|
name: mediawiki
|
||||||
description: MediaWiki-Seiten und -Templates lesen, rendern und schreiben. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite".
|
description: MediaWiki-Seiten und -Templates lesen, rendern, schreiben und deren Parser-Cache leeren. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite", or when a page shows stale API data.
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# MediaWiki (wiki.py)
|
# MediaWiki (wiki.py)
|
||||||
@@ -37,6 +37,8 @@ oder anderen direkten Server-Eingriffen. Reine Lese-/Render-Checks
|
|||||||
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
|
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
|
||||||
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern
|
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern
|
||||||
./wiki.py -w wiki update "Titel" - --summary "..." # Inhalt von stdin
|
./wiki.py -w wiki update "Titel" - --summary "..." # Inhalt von stdin
|
||||||
|
./wiki.py -w wiki purge "Titel" ["Titel 2" ...] [--links] # Parser-Cache leeren
|
||||||
|
./wiki.py -w wiki upload /pfad/bild.png bild.png --description "..." # Datei hochladen
|
||||||
```
|
```
|
||||||
|
|
||||||
Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
|
Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
|
||||||
@@ -57,6 +59,27 @@ dann `.env` im aktuellen Verzeichnis, dann bereits gesetzte Env-Variablen). Sieh
|
|||||||
|
|
||||||
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten.
|
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten.
|
||||||
|
|
||||||
|
## Seiten, die ihre Daten aus einer API holen
|
||||||
|
|
||||||
|
Vorlagen mit `#get_web_data` (External Data) werden **nicht** bei jedem
|
||||||
|
Seitenaufruf ausgeführt. MediaWiki liefert die Seite aus dem Parser-Cache und
|
||||||
|
fragt die API gar nicht erst an. Ändern sich die Daten hinter der API, zeigt
|
||||||
|
die Seite weiter beliebig alte Werte — ohne Fehlermeldung, ohne dass es
|
||||||
|
jemandem auffällt.
|
||||||
|
|
||||||
|
Deshalb gehört nach jedem Datenupload ein Purge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wiki.py -w wiki purge "Nextcloud (Hosting)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--links` schaltet zusätzlich `forcelinkupdate` ein (langsamer, aktualisiert
|
||||||
|
die Linktabellen) — für reine Datenaktualisierungen nicht nötig.
|
||||||
|
|
||||||
|
Das betrifft auch die Fehlersuche: Wer nach einer Änderung `render` aufruft und
|
||||||
|
die alten Werte sieht, hat meist nicht die Daten kaputt, sondern nur den Cache
|
||||||
|
vor sich. Erst purgen, dann urteilen.
|
||||||
|
|
||||||
## Hinweise
|
## Hinweise
|
||||||
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
|
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
|
||||||
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
|
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
|
||||||
|
|||||||
@@ -88,13 +88,20 @@ def cmd_search(args: argparse.Namespace) -> None:
|
|||||||
sys.exit(f"Error: unknown namespace '{ns_key}'. "
|
sys.exit(f"Error: unknown namespace '{ns_key}'. "
|
||||||
f"Known: all, {', '.join(NAMESPACES)}")
|
f"Known: all, {', '.join(NAMESPACES)}")
|
||||||
|
|
||||||
result = site.api(
|
try:
|
||||||
"query", list="search",
|
result = site.api(
|
||||||
srsearch=args.query,
|
"query", list="search",
|
||||||
srnamespace=ns,
|
srsearch=args.query,
|
||||||
srlimit=50,
|
srnamespace=ns,
|
||||||
srprop="title|snippet",
|
srlimit=50,
|
||||||
)
|
srprop="title|snippet",
|
||||||
|
)
|
||||||
|
except mwclient.errors.APIError as exc:
|
||||||
|
if exc.code == "cirrussearch-parse-error":
|
||||||
|
sys.exit(f"Error: Suchanfrage nicht verstanden: {args.query!r}\n"
|
||||||
|
f"Hinweis: 'insource:wort' funktioniert, die Regex-Form "
|
||||||
|
f"'insource:/muster/' ist auf dieser Instanz nicht freigeschaltet.")
|
||||||
|
sys.exit(f"Error: Suche fehlgeschlagen ({exc.code}): {exc.info}")
|
||||||
hits = result["query"]["search"]
|
hits = result["query"]["search"]
|
||||||
if not hits:
|
if not hits:
|
||||||
print("(keine Ergebnisse)", file=sys.stderr)
|
print("(keine Ergebnisse)", file=sys.stderr)
|
||||||
@@ -110,6 +117,25 @@ def cmd_render(args: argparse.Namespace) -> None:
|
|||||||
print(result["parse"]["text"]["*"], end="")
|
print(result["parse"]["text"]["*"], end="")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_purge(args: argparse.Namespace) -> None:
|
||||||
|
"""Parser-Cache einer oder mehrerer Seiten leeren.
|
||||||
|
|
||||||
|
Noetig fuer Seiten, die ihre Daten per #get_web_data aus einer API holen:
|
||||||
|
MediaWiki rendert sonst aus dem Parser-Cache weiter und fragt die API gar
|
||||||
|
nicht erst an - die Seite zeigt dann beliebig alte Werte, ohne dass das
|
||||||
|
irgendwo auffaellt.
|
||||||
|
"""
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
result = site.api("purge", titles="|".join(args.titles),
|
||||||
|
forcelinkupdate=1 if args.links else None)
|
||||||
|
for entry in result.get("purge", []):
|
||||||
|
title = entry.get("title", "?")
|
||||||
|
if "missing" in entry:
|
||||||
|
print(f"Error: page '{title}' does not exist", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Purged '{title}'")
|
||||||
|
|
||||||
|
|
||||||
def cmd_upload(args: argparse.Namespace) -> None:
|
def cmd_upload(args: argparse.Namespace) -> None:
|
||||||
path = Path(args.file_path)
|
path = Path(args.file_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -161,6 +187,12 @@ def main() -> None:
|
|||||||
render_p.add_argument("title", help="Page title")
|
render_p.add_argument("title", help="Page title")
|
||||||
render_p.set_defaults(func=cmd_render)
|
render_p.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
|
purge_p = sub.add_parser("purge", help="Clear the parser cache of one or more pages")
|
||||||
|
purge_p.add_argument("titles", nargs="+", help="Page title(s)")
|
||||||
|
purge_p.add_argument("--links", action="store_true",
|
||||||
|
help="Also run forcelinkupdate (slower, updates link tables)")
|
||||||
|
purge_p.set_defaults(func=cmd_purge)
|
||||||
|
|
||||||
upload_p = sub.add_parser("upload", help="Upload a local file to the wiki file store")
|
upload_p = sub.add_parser("upload", help="Upload a local file to the wiki file store")
|
||||||
upload_p.add_argument("file_path", help="Absolute path to the local file")
|
upload_p.add_argument("file_path", help="Absolute path to the local file")
|
||||||
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)")
|
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redmine",
|
"name": "redmine",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Redmine via REST-API: Projekte/Tickets/Versionen/News auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
|
"description": "Redmine via REST-API: Projekte/Tickets/Versionen/News/Wiki-Seiten auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "inmedias.it"
|
"name": "inmedias.it"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: redmine
|
name: redmine
|
||||||
description: Redmine-Projekte, -Tickets, -Versionen und -News lesen/anlegen/aktualisieren via REST-API. Use when the user mentions "Redmine", "Ticket", "Issue in Redmine", "Meilenstein/Milestone/Version", "Projektstand/News" or die Projekte "pg-wissen"/"devops".
|
description: Redmine-Projekte, -Tickets, -Versionen, -News und Projekt-Wikis lesen/anlegen/aktualisieren via REST-API. Use when the user mentions "Redmine", "Ticket", "Issue in Redmine", "Meilenstein/Milestone/Version", "Projektstand/News", "Redmine-Wiki" or die Projekte "pg-wissen"/"devops".
|
||||||
version: 0.2.0
|
version: 0.3.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# Redmine (redmine.py)
|
# Redmine (redmine.py)
|
||||||
|
|
||||||
Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API:
|
Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API:
|
||||||
Projekte, Tickets, Versionen und News auflisten, anzeigen, anlegen,
|
Projekte, Tickets, Versionen, News und Projekt-Wikis auflisten, anzeigen,
|
||||||
aktualisieren und kommentieren - über die mitgelieferte, self-contained
|
anlegen, aktualisieren und kommentieren - über die mitgelieferte, self-contained
|
||||||
`redmine.py`. Namen werden auf IDs aufgelöst (Projekt, Tracker, Status,
|
`redmine.py`. Namen werden auf IDs aufgelöst (Projekt, Tracker, Status,
|
||||||
Priorität, Version).
|
Priorität, Version).
|
||||||
|
|
||||||
@@ -94,6 +94,34 @@ Zwei Eigenheiten der Redmine-API:
|
|||||||
|
|
||||||
Format ist Markdown, wie bei Tickets.
|
Format ist Markdown, wie bei Tickets.
|
||||||
|
|
||||||
|
### Projekt-Wiki
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py wiki --project devops # Seiten auflisten
|
||||||
|
./redmine.py wiki --project devops --title Wiki # Seite anzeigen
|
||||||
|
./redmine.py wiki --project devops --title Wiki --version 2
|
||||||
|
|
||||||
|
./redmine.py wiki --project devops --title "Bericht" \
|
||||||
|
--text-file bericht.md --comment "Erhebung vom 13.10."
|
||||||
|
|
||||||
|
./redmine.py wiki --project devops --title "Unterseite" \
|
||||||
|
--text-file u.md --parent "Bericht" # Seite einhängen
|
||||||
|
```
|
||||||
|
|
||||||
|
Drei Eigenheiten:
|
||||||
|
|
||||||
|
- **Anlegen und Ändern sind derselbe `PUT`.** Ein Tippfehler im Titel erzeugt
|
||||||
|
sonst stillschweigend eine neue Seite. `redmine.py` sieht deshalb vorher nach
|
||||||
|
und meldet „Angelegt" bzw. „Geändert".
|
||||||
|
- **Leerzeichen werden zu Unterstriche.** Redmine normalisiert den Titel; beide
|
||||||
|
Schreibweisen treffen dieselbe Seite.
|
||||||
|
- **Die Startseite heißt `Wiki`.** Existiert sie nicht, ist das Projekt-Wiki
|
||||||
|
leer und `/projects/<kennung>/wiki` läuft ins Leere. Eine Seite mit genau
|
||||||
|
diesem Titel legt die Startseite an.
|
||||||
|
|
||||||
|
Löschen deckt `redmine.py` bewusst nicht ab - dafür
|
||||||
|
`DELETE /projects/<kennung>/wiki/<Titel>.json`.
|
||||||
|
|
||||||
## Zugangsdaten
|
## Zugangsdaten
|
||||||
|
|
||||||
`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen
|
`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ Beispiele:
|
|||||||
uv run redmine.py news --project devops
|
uv run redmine.py news --project devops
|
||||||
uv run redmine.py news --project devops --title "Kurz" --description-file text.md
|
uv run redmine.py news --project devops --title "Kurz" --description-file text.md
|
||||||
uv run redmine.py news --id 1 --description-file korrigiert.md
|
uv run redmine.py news --id 1 --description-file korrigiert.md
|
||||||
|
uv run redmine.py wiki --project devops
|
||||||
|
uv run redmine.py wiki --project devops --title Wiki
|
||||||
|
uv run redmine.py wiki --project devops --title Bericht --text-file bericht.md
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -32,6 +35,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -317,6 +321,64 @@ def cmd_news(s, a):
|
|||||||
print(f"Angelegt in Projekt {pid} -> {s.base}/projects/{pid}/news")
|
print(f"Angelegt in Projekt {pid} -> {s.base}/projects/{pid}/news")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_wiki(s, a):
|
||||||
|
"""Wiki-Seiten eines Projekts auflisten, lesen oder schreiben.
|
||||||
|
|
||||||
|
Drei Betriebsarten, unterschieden an den Angaben:
|
||||||
|
ohne --title Seiten des Projekts auflisten
|
||||||
|
--title Seite anzeigen
|
||||||
|
--title mit --text/--text-file Seite schreiben (PUT legt an oder ändert)
|
||||||
|
|
||||||
|
Redmine nimmt zum Schreiben denselben PUT für Anlegen und Ändern. Damit ein
|
||||||
|
Tippfehler im Titel nicht unbemerkt eine neue Seite erzeugt, wird vorher
|
||||||
|
nachgesehen, ob die Seite schon existiert, und das Ergebnis entsprechend
|
||||||
|
gemeldet.
|
||||||
|
|
||||||
|
Die Startseite eines Projekt-Wikis heißt bei Redmine standardmäßig `Wiki`.
|
||||||
|
"""
|
||||||
|
pid = resolve_project(s, a.project)
|
||||||
|
base = f"/projects/{pid}/wiki"
|
||||||
|
|
||||||
|
if not a.title:
|
||||||
|
pages = api(s, "GET", f"{base}/index.json").get("wiki_pages", [])
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(pages, ensure_ascii=False, indent=2))
|
||||||
|
if not pages:
|
||||||
|
return print("— keine Wiki-Seiten")
|
||||||
|
for w in sorted(pages, key=lambda x: x["title"]):
|
||||||
|
eltern = w.get("parent", {}).get("title")
|
||||||
|
print(f" v{w.get('version', '?'):<3} {str(w.get('updated_on', ''))[:10]} "
|
||||||
|
f"{w['title']}" + (f" (unter {eltern})" if eltern else ""))
|
||||||
|
return
|
||||||
|
|
||||||
|
titel = quote(a.title, safe="")
|
||||||
|
text = read_text(a.text, a.text_file)
|
||||||
|
|
||||||
|
if text is None:
|
||||||
|
pfad = f"{base}/{titel}" + (f"/{a.version}" if a.version else "") + ".json"
|
||||||
|
w = api(s, "GET", pfad).get("wiki_page", {})
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(w, ensure_ascii=False, indent=2))
|
||||||
|
autor = w.get("author", {}).get("name", "?")
|
||||||
|
print(f"# {w.get('title')} (v{w.get('version')}, {autor}, "
|
||||||
|
f"{str(w.get('updated_on', ''))[:16]})")
|
||||||
|
print(f"# {s.base}/projects/{pid}/wiki/{titel}")
|
||||||
|
print("---")
|
||||||
|
return print(w.get("text", ""))
|
||||||
|
|
||||||
|
seite = {"text": text}
|
||||||
|
if a.comment:
|
||||||
|
seite["comments"] = a.comment
|
||||||
|
if a.parent:
|
||||||
|
seite["parent_title"] = a.parent
|
||||||
|
# Vorher nachsehen: 404 heißt anlegen, alles andere ändern.
|
||||||
|
r = s.request("GET", f"{s.base}{base}/{titel}.json")
|
||||||
|
neu = r.status_code == 404
|
||||||
|
api(s, "PUT", f"{base}/{titel}.json", data=json.dumps({"wiki_page": seite}))
|
||||||
|
print(f"{'Angelegt' if neu else 'Geändert'}: {a.title} "
|
||||||
|
f"-> {s.base}/projects/{pid}/wiki/{titel}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_meta(s, a):
|
def cmd_meta(s, a):
|
||||||
spec = {
|
spec = {
|
||||||
"trackers": ("/trackers.json", "trackers"),
|
"trackers": ("/trackers.json", "trackers"),
|
||||||
@@ -387,6 +449,15 @@ def main():
|
|||||||
pv.add_argument("--due", help="Fälligkeitsdatum (YYYY-MM-DD)")
|
pv.add_argument("--due", help="Fälligkeitsdatum (YYYY-MM-DD)")
|
||||||
pv.add_argument("--description", help="Beschreibung")
|
pv.add_argument("--description", help="Beschreibung")
|
||||||
|
|
||||||
|
pw = sub.add_parser("wiki", help="Wiki-Seiten auflisten, lesen oder schreiben")
|
||||||
|
pw.add_argument("--project", required=True, help="Projekt (ID/Identifier/Name)")
|
||||||
|
pw.add_argument("--title", help="Seitentitel - ohne Angabe werden die Seiten aufgelistet")
|
||||||
|
pw.add_argument("--text", help="Seiteninhalt; gesetzt heißt: schreiben statt anzeigen")
|
||||||
|
pw.add_argument("--text-file", help="Datei oder '-' für stdin")
|
||||||
|
pw.add_argument("--comment", help="Änderungskommentar (erscheint in der Historie)")
|
||||||
|
pw.add_argument("--parent", help="Titel der übergeordneten Seite")
|
||||||
|
pw.add_argument("--version", help="alte Fassung anzeigen (Versionsnummer)")
|
||||||
|
|
||||||
pm = sub.add_parser("meta", help="Stammdaten auflisten")
|
pm = sub.add_parser("meta", help="Stammdaten auflisten")
|
||||||
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
|
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
|
||||||
|
|
||||||
@@ -395,7 +466,7 @@ def main():
|
|||||||
{
|
{
|
||||||
"me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show,
|
"me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show,
|
||||||
"create": cmd_create, "update": cmd_update, "comment": cmd_comment,
|
"create": cmd_create, "update": cmd_update, "comment": cmd_comment,
|
||||||
"news": cmd_news, "versions": cmd_versions, "meta": cmd_meta,
|
"news": cmd_news, "versions": cmd_versions, "wiki": cmd_wiki, "meta": cmd_meta,
|
||||||
}[a.cmd](s, a)
|
}[a.cmd](s, a)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user