10 Commits
Author SHA1 Message Date
sko 4675ebce6a Merge pull request 'redmine: Projekt-Wikis lesen und schreiben (0.3.0)' (#13) from feat/redmine-wiki into main
Reviewed-on: #13
2026-10-15 04:34:59 +02:00
skoandClaude Opus 5 e1ed8688f0 redmine: Projekt-Wikis lesen und schreiben (0.3.0)
Uebernimmt redmine.py aus inmedias.it/dokumentierer@4159e0a, byte-identisch.

Neuer Unterbefehl `wiki` nach dem Muster von `news`: ohne --title auflisten,
mit --title anzeigen, zusaetzlich mit --text/--text-file schreiben. Dazu
--comment, --parent und --version.

Mitgezogen: SKILL.md (Befehlsreferenz und Frontmatter), plugin.json und
marketplace.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:44:46 +02:00
sko d00d195ac3 Merge pull request 'mediawiki: Suchfehler lesbar statt als Stacktrace (0.1.1)' (#11) from fix/wiki-suchfehler into main
Reviewed-on: #11
2026-10-08 06:02:16 +02:00
sko a8688302ac Merge pull request 'humhub: Hinweis auf Emoji-Ersetzung bei :) in Fachtext (0.4.1)' (#12) from docs/humhub-emoticons into main
Reviewed-on: #12
2026-10-08 05:50:13 +02:00
skoandClaude Opus 5.5 2e19639e12 humhub: Hinweis auf Emoji-Ersetzung bei :) in Fachtext (0.4.1)
'(0.0.0.0 und ::)' wurde in einem Schraubraum-Post zu einem Smiley.
IPv6-Adressen, Ports und Pfade vor Klammern in Backticks setzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:43 +02:00
skoandClaude Opus 5 72a1ca284a mediawiki: Suchfehler lesbar statt als Stacktrace (0.1.1)
Gleichstand mit inmedias.it/dokumentierer d1eeb94.

Eine von CirrusSearch abgelehnte Suchanfrage liess mwclient.errors.APIError
bis nach oben durchschlagen - der Benutzer sah einen Stacktrace statt einer
Meldung. cmd_search faengt den Fehler jetzt ab und beendet mit Code 1.

Fuer cirrussearch-parse-error gibt es einen eigenen Hinweis: "insource:wort"
funktioniert, die Regex-Form "insource:/muster/" nicht. Letztere braucht das
Elasticsearch-Plugin wikimedia-extra, das auf unserer Instanz fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 11:51:07 +02:00
sko 847f8ab3f4 Merge pull request 'humhub: mail-send loest Empfaenger zu GUIDs auf' (#10) from fix/humhub-mail-guid into main
Reviewed-on: #10
2026-09-25 11:07:45 +02:00
jhsandClaude Opus 5 10ea9277c9 humhub: Probelauf, Zustellprüfung und mail-user-remove (0.4.0)
Die API hat eine Direktnachricht an einen anderen als den in `recipient`
genannten Benutzer zugestellt, obwohl die ID über /user verifiziert war.
Welche Kennung sie wirklich erwartet, ist ungeklärt — und löschen lässt
sich eine Fehlzustellung nicht: DELETE /mail/<id> gibt es in dieser
Version nicht, über die Oberfläche kommt man auch nicht dran.

Deshalb drei Änderungen:

- mail-send sendet nur noch mit --send; ohne das Flag löst es den
  Empfänger auf und zeigt Namen und ID.
- Nach dem Versand wird die Teilnehmerliste gelesen und gemeldet, wer die
  Konversation tatsächlich bekommen hat.
- mail-user-remove nimmt einen Teilnehmer wieder heraus
  (DELETE /mail/<id>/user/<userid>) — das einzige Gegenmittel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 08:31:43 +02:00
jhsandClaude Opus 5 1c9f95a11f humhub: Version 0.3.1
Ohne Versionssprung zieht 'claude plugin marketplace update' den Fix
nicht als neue Fassung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 19:08:58 +02:00
jhsandClaude Opus 5 57c1910734 humhub: mail-send loest Empfaenger zu GUIDs auf
POST /mail nimmt als recipient ausschliesslich GUIDs an. Mit numerischen
User-IDs antwortet die API "400 Validation failed" und "Du kannst keine
Nachricht ohne Empfaenger senden!" - eine Meldung, die nach einem leeren
Feld klingt, obwohl das Feld gefuellt ist.

resolve_user gibt deshalb die GUID zurueck statt der ID; user_guid holt
sie ueber /user/<id> nach, wenn die Benutzerliste sie nicht mitliefert.
Geprueft gegen humhub.inmedias.it mit Benutzername, Profil-URL und
numerischer ID; der Versand mit GUID ist belegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 18:58:02 +02:00
9 changed files with 277 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -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 -1
View File
@@ -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"
+42 -2
View File
@@ -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
+112 -10
View File
@@ -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 -1
View File
@@ -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"
+14 -7
View File
@@ -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)
+2 -2
View File
@@ -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"
} }
+32 -4
View File
@@ -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
+72 -1
View File
@@ -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)