Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a1ca284a | ||
|
|
7efb302c10 | ||
|
|
ab56ba0c64 | ||
|
|
6613c9ef29 | ||
|
|
21d709afbd | ||
|
|
2bfc62bcb8 | ||
|
|
e00395fc64 | ||
|
|
4a20da2ed2 | ||
|
|
2a02dac74e | ||
|
|
75e99dd0b8 | ||
|
|
dc3e6a0c68 | ||
|
|
28ec699e51 | ||
|
|
c09ea07d3f | ||
|
|
36dcb843b9 | ||
|
|
919cd52bff | ||
|
|
27f40ee18a | ||
|
|
64744a213d | ||
|
|
04886d73f5 | ||
|
|
aa9888700d | ||
|
|
437bd1c1ee | ||
|
|
0fd228b962 | ||
|
|
ad893e20d5 |
No files matched your search
@@ -32,6 +32,26 @@
|
|||||||
"name": "mediawiki",
|
"name": "mediawiki",
|
||||||
"source": "./plugins/mediawiki",
|
"source": "./plugins/mediawiki",
|
||||||
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (Instanzen wiki & devwiki; Zugangsdaten lokal)."
|
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (Instanzen wiki & devwiki; Zugangsdaten lokal)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grill-me",
|
||||||
|
"source": "./plugins/grill-me",
|
||||||
|
"description": "Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "einfaches-technisches-deutsch",
|
||||||
|
"source": "./plugins/einfaches-technisches-deutsch",
|
||||||
|
"description": "Antworten in einfachem technischem Deutsch (nach ASD-STE100, ins Deutsche übertragen): Aktiv, kurze Sätze, ein Gedanke pro Satz — Technik bleibt exakt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "klare-sprache",
|
||||||
|
"source": "./plugins/klare-sprache",
|
||||||
|
"description": "Texte in klarer, verständlicher Sprache nach ISO 24495-1:2023 (Plain Language): zielgruppenorientiert — finden, verstehen, nutzen."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# lokale Umgebungs-/Secret-Dateien
|
||||||
|
.env
|
||||||
@@ -5,26 +5,28 @@ und kann einzeln installiert werden — niemand bekommt alles auf einmal.
|
|||||||
|
|
||||||
## Einrichtung (einmalig)
|
## Einrichtung (einmalig)
|
||||||
|
|
||||||
`/plugin marketplace add` akzeptiert nur `owner/repo` (immer GitHub!), `https://…`
|
Dieses Repo ist öffentlich lesbar. Der Marketplace lässt sich deshalb direkt über
|
||||||
oder einen lokalen `./path` — **kein** `ssh://`. Da dieses Repo privat ist und der
|
seine HTTPS-URL hinzufügen — ohne Zugangsdaten und ohne git-Konfiguration:
|
||||||
Plugin-Clone nicht interaktiv nach Zugangsdaten fragen kann, muss git HTTPS-Zugriffe
|
|
||||||
auf das Gitea transparent auf SSH umleiten. Einmalig pro Rechner:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. git so konfigurieren, dass https auf dieses Gitea über SSH (Key) läuft
|
|
||||||
git config --global url."ssh://gitea@gitea.inmedias.it:2222/".insteadOf "https://gitea.inmedias.it/"
|
|
||||||
|
|
||||||
# 2. Marketplace mit der HTTPS-URL hinzufügen (klont real über SSH)
|
|
||||||
/plugin marketplace add https://gitea.inmedias.it/inmedias.it/claude-plugins.git
|
/plugin marketplace add https://gitea.inmedias.it/inmedias.it/claude-plugins.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Voraussetzung: dein Gitea-SSH-Key ist auf Port 2222 eingerichtet.
|
Zum **Pushen** ist weiterhin ein Gitea-SSH-Key auf Port 2222 nötig.
|
||||||
|
|
||||||
> Hinweis: Die `insteadOf`-Regel gilt für **alle** git-Operationen auf
|
> Hinweis: `/plugin marketplace add` akzeptiert nur `owner/repo` (immer GitHub!),
|
||||||
> `gitea.inmedias.it`, nicht nur für Plugins.
|
> `https://…` oder einen lokalen `./path` — **kein** `ssh://`.
|
||||||
>
|
>
|
||||||
> Alternative ohne git-Config: Repo `inmedias.it/claude-plugins` intern/öffentlich
|
> Falls das Repo wieder auf privat gestellt wird, kann der Plugin-Clone nicht
|
||||||
> lesbar machen — dann reicht die HTTPS-URL direkt (SSH-Key trotzdem für Push nötig).
|
> interaktiv nach Zugangsdaten fragen. Dann muss git HTTPS-Zugriffe auf das Gitea
|
||||||
|
> transparent auf SSH umleiten:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> git config --global url."ssh://gitea@gitea.inmedias.it:2222/".insteadOf "https://gitea.inmedias.it/"
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Diese Regel gilt für **alle** git-Operationen auf `gitea.inmedias.it`, nicht nur
|
||||||
|
> für Plugins.
|
||||||
|
|
||||||
## Plugin installieren
|
## Plugin installieren
|
||||||
|
|
||||||
@@ -75,6 +77,100 @@ natürlicher klingen. Basiert auf Wikipedias „Signs of AI writing".
|
|||||||
/plugin install humanizer@inmedias-claude-plugins
|
/plugin install humanizer@inmedias-claude-plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### humhub
|
||||||
|
|
||||||
|
HumHub (humhub.inmedias.it) über die REST-API: Wiki-Seiten, Posts und Umfragen auflisten,
|
||||||
|
anzeigen, anlegen und aktualisieren — via mitgelieferter, self-contained
|
||||||
|
`humhub.py` (`wiki-list/get/update/create`, `post-list/create/update`,
|
||||||
|
`poll-list/create`).
|
||||||
|
|
||||||
|
- Voraussetzung: `HUMHUB_API_TOKEN` als Umgebungsvariable bzw. dotenv-Datei
|
||||||
|
(kein Token im Plugin). Token nur als berechtigter Benutzer unter
|
||||||
|
https://humhub.inmedias.it/rest/admin/index.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install humhub@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### mediawiki
|
||||||
|
|
||||||
|
MediaWiki-Seiten/Templates lesen, rendern und schreiben — via mitgelieferter,
|
||||||
|
self-contained `wiki.py` (`fetch`/`render`/`search`/`update`). Instanzen `wiki`
|
||||||
|
(Prod, wiki.inmedias.it) und `devwiki` per `-w`.
|
||||||
|
|
||||||
|
- Voraussetzung: eigene Zugangsdaten (`MEDIAWIKI_USER`/`MEDIAWIKI_PASSWORD`,
|
||||||
|
optional `DEVMEDIAWIKI_*`) als Umgebungsvariable bzw. dotenv-Datei (kein Secret
|
||||||
|
im Plugin).
|
||||||
|
- Regel: Wiki-Seiten nur über `wiki.py`/MCP bearbeiten, nicht per
|
||||||
|
Maintenance-Skript am Server vorbei.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install mediawiki@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### redmine
|
||||||
|
|
||||||
|
Redmine (redmine.inmedias.it) über die REST-API: Projekte, Tickets, Versionen
|
||||||
|
und News auflisten, anzeigen, anlegen, aktualisieren und kommentieren — via
|
||||||
|
mitgelieferter, self-contained `redmine.py` (`me`/`projects`/`issues`/`show`/
|
||||||
|
`create`/`update`/`comment`/`news`/`versions`/`meta`). Namen (Projekt/Tracker/
|
||||||
|
Status/Priorität/Version) werden auf IDs aufgelöst.
|
||||||
|
|
||||||
|
- Voraussetzung: `REDMINE_URL` und persönlicher `REDMINE_API_KEY` als
|
||||||
|
Umgebungsvariable bzw. dotenv-Datei (kein Key im Plugin). API-Key unter
|
||||||
|
https://redmine.inmedias.it/my/account.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install redmine@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### grill-me
|
||||||
|
|
||||||
|
Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee —
|
||||||
|
geht den Entscheidungsbaum Zweig für Zweig durch, eine Frage nach der anderen (je
|
||||||
|
mit Empfehlung), bis ein gemeinsames Verständnis steht. Startet die Umsetzung erst
|
||||||
|
nach deiner Bestätigung.
|
||||||
|
|
||||||
|
- Kein Setup, keine Zugangsdaten. Auslösen mit „grill mich" bzw. wenn du deinen
|
||||||
|
Plan stresstesten willst.
|
||||||
|
- Deutsche Adaption des englischen Originals von Matt Pocock:
|
||||||
|
[mattpocock/skills · grill-me](https://github.com/mattpocock/skills/blob/main/skills/productivity/grill-me/SKILL.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install grill-me@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### einfaches-technisches-deutsch
|
||||||
|
|
||||||
|
Setzt einen festen Schreibstil für Antworten: nach ASD-STE100 (Simplified
|
||||||
|
Technical English), ins Deutsche übertragen. Aktiv, Sätze mit höchstens 20
|
||||||
|
Wörtern, ein Gedanke pro Satz, gleiche Wörter für gleiche Dinge. Technische
|
||||||
|
Angaben (Pfade, Befehle, Zahlen, Preise) bleiben exakt.
|
||||||
|
|
||||||
|
- Kein Setup, keine Zugangsdaten. Auslösen mit „schreib einfacher", „einfaches
|
||||||
|
Deutsch", „STE" o. ä.; gilt dann für die weitere Sitzung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install einfaches-technisches-deutsch@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### klare-sprache
|
||||||
|
|
||||||
|
Schreibt oder überarbeitet Texte in klarer, verständlicher Sprache nach
|
||||||
|
ISO 24495-1:2023 (Plain Language). Anders als `einfaches-technisches-deutsch`
|
||||||
|
kein starres Regelwerk, sondern zielgruppen- und prozessorientiert: Die Leser
|
||||||
|
sollen leicht **finden**, **verstehen** und **nutzen** können. Vier Prinzipien
|
||||||
|
(relevant, auffindbar, verständlich, nutzbar) mit Checkliste; technische Angaben
|
||||||
|
bleiben exakt.
|
||||||
|
|
||||||
|
- Kein Setup, keine Zugangsdaten. Auslösen mit „klare Sprache", „verständlich
|
||||||
|
schreiben", „ISO 24495" o. ä. Ohne bekannte Zielgruppe fragt der Skill zuerst
|
||||||
|
nach.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install klare-sprache@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "einfaches-technisches-deutsch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Antworten in einfachem technischem Deutsch schreiben (nach ASD-STE100, ins Deutsche übertragen): Aktiv, kurze Sätze, ein Gedanke pro Satz — Technik bleibt exakt.",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: einfaches-technisches-deutsch
|
||||||
|
description: Antworten in einfachem technischem Deutsch schreiben (nach ASD-STE100 Simplified Technical English, ins Deutsche übertragen). Use when the user asks to write clearly/simply, mentions "einfache Sprache", "einfaches Deutsch", "vereinfachtes technisches Deutsch", "verständlich schreiben", "schreib einfacher", "STE", "Simplified Technical English", or wants replies that stay short and precise.
|
||||||
|
version: 0.1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Einfaches technisches Deutsch
|
||||||
|
|
||||||
|
Dieser Skill setzt einen festen Schreibstil. Der Stil folgt ASD-STE100
|
||||||
|
(Simplified Technical English) und ist ins Deutsche übertragen. Ziel: Antworten
|
||||||
|
bleiben kurz, klar und eindeutig.
|
||||||
|
|
||||||
|
## Wann anwenden
|
||||||
|
|
||||||
|
Wende den Stil an, sobald der Nutzer ihn verlangt. Er gilt dann für alle
|
||||||
|
weiteren Antworten in der Sitzung, bis der Nutzer ihn wieder abschaltet.
|
||||||
|
|
||||||
|
Der Nutzer kann den Stil auch dauerhaft wollen. In dem Fall speichere die Regel
|
||||||
|
im Memory und wende sie in jeder Sitzung an.
|
||||||
|
|
||||||
|
## Die Regeln
|
||||||
|
|
||||||
|
Halte dich an diese Regeln:
|
||||||
|
|
||||||
|
- Ich schreibe im Aktiv.
|
||||||
|
- Ich schreibe Sätze mit höchstens 20 Wörtern.
|
||||||
|
- Ich schreibe einen Gedanken pro Satz.
|
||||||
|
- Ich nutze einfache Zeitformen: Präsens, Präteritum und Futur.
|
||||||
|
- Ich nutze dasselbe Wort für dieselbe Sache.
|
||||||
|
- Ich nutze keine Redewendungen, keinen Slang und keinen unnötigen Jargon.
|
||||||
|
- Ich schreibe Absätze mit höchstens 6 Sätzen.
|
||||||
|
|
||||||
|
## Technische Angaben bleiben exakt
|
||||||
|
|
||||||
|
Der Stil vereinfacht die Sprache, nicht die Technik. Technische Angaben bleiben
|
||||||
|
unverändert. Das gilt für Dateipfade, Funktionsnamen, Spaltennamen, Befehle,
|
||||||
|
Preise und Zahlen.
|
||||||
|
|
||||||
|
Beispiel: Ich schreibe `workers/updatePricing.php` und `4.855 €` vollständig aus.
|
||||||
|
|
||||||
|
## Vorgehen
|
||||||
|
|
||||||
|
1. Bestätige kurz, dass du den Stil ab jetzt nutzt.
|
||||||
|
2. Schreibe die Antwort direkt im Stil. Die Antwort ist zugleich das Beispiel.
|
||||||
|
3. Prüfe vor dem Senden: Aktiv, kurze Sätze, ein Gedanke pro Satz, gleiche
|
||||||
|
Wörter, exakte Technik.
|
||||||
|
4. Will der Nutzer den Stil dauerhaft, lege ein Memory an.
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
Der Stil gilt für Fließtext. Er gilt nicht für Code, Log-Ausgaben,
|
||||||
|
Fehlermeldungen oder Zitate. Diese gibst du unverändert wieder.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "grill-me",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht.",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: grill-me
|
||||||
|
description: Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht. Nutzen, wenn der User seinen Plan stresstesten will oder "grill mich" sagt.
|
||||||
|
---
|
||||||
|
|
||||||
|
Interviewe mich gnadenlos zu jedem Aspekt dieses Vorhabens, bis wir ein gemeinsames Verständnis erreicht haben. Gehe jeden Zweig des Entscheidungsbaums durch und löse die Abhängigkeiten zwischen den Entscheidungen nacheinander auf. Gib mir zu jeder Frage deine empfohlene Antwort dazu.
|
||||||
|
|
||||||
|
Stelle die Fragen einzeln und warte auf meine Antwort, bevor du weitermachst. Mehrere Fragen auf einmal sind verwirrend.
|
||||||
|
|
||||||
|
Wenn sich ein Fakt durch Erkunden der Umgebung (Dateien, Tools usw.) herausfinden lässt, schlag ihn selbst nach, statt mich zu fragen. Die Entscheidungen aber gehören mir: Leg mir jede einzeln vor und warte auf meine Antwort.
|
||||||
|
|
||||||
|
Fang nicht mit der Umsetzung an, bevor ich bestätigt habe, dass wir ein gemeinsames Verständnis erreicht haben.
|
||||||
|
|
||||||
|
<!-- Deutsche Adaption des englischen Originals von Matt Pocock:
|
||||||
|
https://github.com/mattpocock/skills/blob/main/skills/productivity/grill-me/SKILL.md -->
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "humhub",
|
"name": "humhub",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"description": "HumHub via REST-API: Wiki-Seiten und Posts 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: humhub
|
name: humhub
|
||||||
description: HumHub Wiki-Seiten und Posts lesen/erstellen/aktualisieren via REST-API. Use when the user mentions "HumHub", "Space", "Wiki-Seite in HumHub" or "Post/Beitrag".
|
description: HumHub Wiki-Seiten, Posts und Umfragen lesen/erstellen/aktualisieren via REST-API. Use when the user mentions "HumHub", "Space", "Wiki-Seite in HumHub", "Post/Beitrag" or "Umfrage/Poll".
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# HumHub (humhub.py)
|
# HumHub (humhub.py)
|
||||||
|
|
||||||
Interagiert mit HumHub (https://humhub.inmedias.it) über die REST-API:
|
Interagiert mit HumHub (https://humhub.inmedias.it) über die REST-API:
|
||||||
Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren — über die
|
Wiki-Seiten, Posts und Umfragen auflisten, anzeigen, anlegen und aktualisieren — über die
|
||||||
mitgelieferte, self-contained `humhub.py`.
|
mitgelieferte, self-contained `humhub.py`.
|
||||||
|
|
||||||
## Ausführen
|
## Ausführen
|
||||||
@@ -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
|
||||||
@@ -36,10 +59,69 @@ mitgelieferte, self-contained `humhub.py`.
|
|||||||
./humhub.py post-update <id> [datei|-]
|
./humhub.py post-update <id> [datei|-]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Umfragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./humhub.py poll-list <container_id> [--limit N]
|
||||||
|
./humhub.py poll-create <container_id> "<frage>" --answers "A|B|C" \
|
||||||
|
[--file beschreibung.md] [--anonymous] [--multiple] [--hide-results]
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwortoptionen mit `|` trennen, mindestens zwei. Der Beschreibungstext kommt
|
||||||
|
wie überall aus einer Datei oder `-`; Markdown ist erlaubt. Ohne `--anonymous`
|
||||||
|
ist sichtbar, wer wie gestimmt hat — bei Anmeldungen mit begrenzten Plätzen ist
|
||||||
|
das der Sinn der Sache.
|
||||||
|
|
||||||
|
Zwei Eigenheiten der Polls-API, die zu `HTTP 500 "Internal error while save a
|
||||||
|
poll!"` führen, wenn man dem Muster der Post-API folgt: die Felder gehören
|
||||||
|
unter den Yii-Formnamen **`Poll`** (nicht `data`), und die Optionen heißen beim
|
||||||
|
Anlegen **`newAnswers`** (beim Lesen `answers`). Beim Lesen ist `limit` Pflicht
|
||||||
|
und nicht Komfort — ohne Begrenzung antwortet die Instanz ab ~25 Umfragen
|
||||||
|
ebenfalls mit 500. Ein fehlgeschlagener POST legt nichts an; vor einem zweiten
|
||||||
|
Versuch trotzdem mit `poll-list` prüfen.
|
||||||
|
|
||||||
|
### Kommentare
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./humhub.py comment-list <content-id|perma-url> # Kommentare eines Inhalts
|
||||||
|
./humhub.py comment-create <content-id|perma-url> [datei|-] # Kommentar schreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide nehmen wie `fetch` eine **Content-ID** oder Perma-URL (nicht die Post-/
|
||||||
|
Wiki-Seiten-ID) und lösen das Zielobjekt selbst auf — funktioniert für Posts
|
||||||
|
und Wiki-Seiten.
|
||||||
|
|
||||||
|
### Direktnachrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./humhub.py mail-send <empfaenger> "<betreff>" [datei|-]
|
||||||
|
./humhub.py mail-send https://humhub.inmedias.it/u/serge/ "Betreff" entwurf.md
|
||||||
|
./humhub.py mail-send jan.stobbe,26 "Betreff" -
|
||||||
|
./humhub.py mail-reply <konversations-id> [datei|-]
|
||||||
|
```
|
||||||
|
|
||||||
|
Empfänger sind Benutzername, Profil-URL oder numerische ID; mehrere mit Komma.
|
||||||
|
|
||||||
|
`mail-reply` hängt einen Beitrag an eine bestehende Konversation
|
||||||
|
(`POST /mail/<id>/entry`). Die ID steht in der URL der Konversation:
|
||||||
|
`…/mail/mail/index?id=35`.
|
||||||
|
|
||||||
|
**Slug ≠ Benutzername.** Zur Profil-URL `/u/serge/` gehört der Benutzername
|
||||||
|
`Serge`, und `get-by-username` unterscheidet Groß-/Kleinschreibung. Wer den
|
||||||
|
Slug einsetzt, bekommt sonst `User not found!`. `mail-send` fängt das ab: erst
|
||||||
|
`get-by-username`, dann ein Durchlauf der Benutzerliste, der Benutzername und
|
||||||
|
Slug ohne Rücksicht auf Groß-/Kleinschreibung vergleicht.
|
||||||
|
|
||||||
|
Zum Lesen von Konversationen gibt es bewusst keinen Befehl — Nachrichten
|
||||||
|
anderer gehören nicht ungefragt in einen Modellkontext. Auch `mail-reply`
|
||||||
|
holt den Verlauf nicht ab, sondern prüft nur, ob die Konversation erreichbar
|
||||||
|
ist.
|
||||||
|
|
||||||
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. 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
|
||||||
|
|
||||||
|
|||||||
@@ -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})")
|
||||||
@@ -182,6 +212,61 @@ def cmd_post_create(args: argparse.Namespace) -> None:
|
|||||||
print(f"Topics: {', '.join(saved_topics)}")
|
print(f"Topics: {', '.join(saved_topics)}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_poll_list(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
container_id = resolve_container(session, args.container_id)
|
||||||
|
# limit ist Pflicht, nicht Komfort: ohne Begrenzung antwortet die Instanz
|
||||||
|
# bei vielen Umfragen mit HTTP 500 statt mit einer Liste.
|
||||||
|
data = api_get(session, f"/polls/container/{container_id}?limit={args.limit}")
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
print("(keine Umfragen)")
|
||||||
|
return
|
||||||
|
for poll in results:
|
||||||
|
antworten = [a.get("answer", "") for a in poll.get("answers", [])]
|
||||||
|
zu = " [geschlossen]" if poll.get("closed") else ""
|
||||||
|
print(f"ID {poll.get('id')}{zu}: {poll.get('question', '')}")
|
||||||
|
print(f" {' | '.join(antworten)}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_poll_create(args: argparse.Namespace) -> None:
|
||||||
|
"""Umfrage anlegen.
|
||||||
|
|
||||||
|
Zwei Abweichungen von der Post-API, die beide mit HTTP 500
|
||||||
|
("Internal error while save a poll!") enden, wenn man sie übersieht:
|
||||||
|
|
||||||
|
* Der Controller macht `$poll->load(Yii::$app->request->post())` — Yii
|
||||||
|
erwartet die Felder deshalb unter dem Formnamen **`Poll`**, nicht unter
|
||||||
|
`data` wie bei Posts und Wiki-Seiten.
|
||||||
|
* Die Optionen heißen beim Anlegen **`newAnswers`**; `answers` ist die
|
||||||
|
Leseform und wird beim POST ignoriert.
|
||||||
|
|
||||||
|
Quelle: humhub/polls, controllers/rest/PollsController.php + models/Poll.php.
|
||||||
|
Pflicht sind `question` und mindestens zwei Antworten.
|
||||||
|
"""
|
||||||
|
session, _ = get_session()
|
||||||
|
container_id = resolve_container(session, args.container_id)
|
||||||
|
antworten = [a.strip() for a in args.answers.split("|") if a.strip()]
|
||||||
|
if len(antworten) < 2:
|
||||||
|
sys.exit("Error: mindestens zwei Antwortoptionen nötig "
|
||||||
|
"(--answers 'Ja|Nein').")
|
||||||
|
beschreibung = read_file(args.file) if args.file else ""
|
||||||
|
|
||||||
|
payload = {"Poll": {
|
||||||
|
"question": args.question,
|
||||||
|
"description": beschreibung,
|
||||||
|
"newAnswers": antworten,
|
||||||
|
"anonymous": 1 if args.anonymous else 0,
|
||||||
|
"allow_multiple": 1 if args.multiple else 0,
|
||||||
|
"show_result_after_close": 1 if args.hide_results else 0,
|
||||||
|
"is_random": 0,
|
||||||
|
}}
|
||||||
|
data = api_post(session, f"/polls/container/{container_id}", payload)
|
||||||
|
print(f"Umfrage erstellt (ID {data.get('id')}): {data.get('question')}")
|
||||||
|
print("Optionen: " + " | ".join(a.get("answer", "")
|
||||||
|
for a in data.get("answers", [])))
|
||||||
|
|
||||||
|
|
||||||
def cmd_post_update(args: argparse.Namespace) -> None:
|
def cmd_post_update(args: argparse.Namespace) -> None:
|
||||||
session, _ = get_session()
|
session, _ = get_session()
|
||||||
message = read_file(args.file)
|
message = read_file(args.file)
|
||||||
@@ -199,12 +284,249 @@ 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)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comment commands ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def cmd_comment_list(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
content_id = parse_content_ref(args.ref)
|
||||||
|
data = api_get(session, f"/comment/content/{content_id}")
|
||||||
|
results = data.get("results", data if isinstance(data, list) else [])
|
||||||
|
if not results:
|
||||||
|
print("(keine Kommentare)")
|
||||||
|
return
|
||||||
|
for c in results:
|
||||||
|
cid = c.get("id")
|
||||||
|
author = (c.get("createdBy") or {}).get("display_name", "?")
|
||||||
|
created = c.get("createdAt", "")
|
||||||
|
msg = (c.get("message") or "").replace("\n", " ")[:100]
|
||||||
|
print(f"ID {cid:>5} [{created}] {author}: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_comment_create(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
content_id = parse_content_ref(args.ref)
|
||||||
|
message = read_file(args.file)
|
||||||
|
|
||||||
|
envelope = api_get(session, f"/content/{content_id}")
|
||||||
|
meta = envelope.get("metadata", {})
|
||||||
|
model = meta.get("object_model", "")
|
||||||
|
object_id = meta.get("object_id")
|
||||||
|
if not model or not object_id:
|
||||||
|
sys.exit(f"Error: Content {content_id} nicht auflösbar (object_model/object_id fehlen)")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"objectModel": model,
|
||||||
|
"objectId": object_id,
|
||||||
|
"Comment": {"message": message},
|
||||||
|
}
|
||||||
|
data = api_post(session, "/comment", payload)
|
||||||
|
cid = data.get("id")
|
||||||
|
short_model = model.split("\\")[-1]
|
||||||
|
print(f"Kommentar erstellt (ID {cid}) auf {short_model} {object_id}")
|
||||||
|
print(f"Perma: https://humhub.inmedias.it/content/perma?id={content_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mail (Direktnachrichten) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def resolve_user(session: requests.Session, value: str) -> int:
|
||||||
|
"""Resolve a user ID, username, or profile URL (…/u/<slug>/) to a user ID.
|
||||||
|
|
||||||
|
Slug und Benutzername sind nicht dasselbe: zu https://…/u/serge/ gehört der
|
||||||
|
Benutzername "Serge". `get-by-username` kennt nur den Benutzernamen und
|
||||||
|
unterscheidet Groß-/Kleinschreibung — deshalb der Fallback über die
|
||||||
|
Benutzerliste, der auch den Slug vergleicht.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
if value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
name = value
|
||||||
|
if "/" in value:
|
||||||
|
teile = [t for t in urlparse(value).path.split("/") if t]
|
||||||
|
if teile:
|
||||||
|
name = teile[-1]
|
||||||
|
|
||||||
|
r = session.get(f"{BASE_URL}/user/get-by-username",
|
||||||
|
params={"username": name})
|
||||||
|
if r.ok:
|
||||||
|
return int(r.json()["id"])
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
data = api_get(session, f"/user?limit=50&page={page}")
|
||||||
|
results = data.get("results", [])
|
||||||
|
for u in results:
|
||||||
|
username = (u.get("account") or {}).get("username", "")
|
||||||
|
slug_teile = [t for t in urlparse(u.get("url", "")).path.split("/") if t]
|
||||||
|
slug = slug_teile[-1] if slug_teile else ""
|
||||||
|
if name.lower() in (username.lower(), slug.lower()):
|
||||||
|
return int(u["id"])
|
||||||
|
if len(results) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
sys.exit(f"Error: Benutzer {value!r} nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mail_send(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
ids = [resolve_user(session, teil) for teil in args.to.split(",") if teil.strip()]
|
||||||
|
if not ids:
|
||||||
|
sys.exit("Error: kein Empfänger angegeben")
|
||||||
|
message = read_file(args.file)
|
||||||
|
data = api_post(session, "/mail", {
|
||||||
|
"title": args.title,
|
||||||
|
"message": message,
|
||||||
|
"recipient": ids,
|
||||||
|
})
|
||||||
|
empfaenger = ", ".join(str(i) for i in ids)
|
||||||
|
print(f"Nachricht gesendet (Konversation {data.get('id')}) an Benutzer {empfaenger}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mail_reply(args: argparse.Namespace) -> None:
|
||||||
|
"""Antwortet in einer bestehenden Konversation.
|
||||||
|
|
||||||
|
Bewusst ohne Lesebefehl: der Text der Konversation wird nicht abgerufen,
|
||||||
|
nur ihre Erreichbarkeit geprueft.
|
||||||
|
"""
|
||||||
|
session, _ = get_session()
|
||||||
|
message = read_file(args.file)
|
||||||
|
r = session.get(f"{BASE_URL}/mail/{args.id}")
|
||||||
|
if not r.ok:
|
||||||
|
sys.exit(f"Error {r.status_code}: Konversation {args.id} nicht erreichbar")
|
||||||
|
data = api_post(session, f"/mail/{args.id}/entry", {"message": message})
|
||||||
|
print(f"Antwort geschrieben (Eintrag {data.get('id')}) in Konversation {args.id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 +546,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,17 +561,41 @@ 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)
|
||||||
|
|
||||||
|
# comment-list
|
||||||
|
p = sub.add_parser("comment-list", help="Kommentare eines Inhalts auflisten")
|
||||||
|
p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)")
|
||||||
|
p.set_defaults(func=cmd_comment_list)
|
||||||
|
|
||||||
|
# comment-create
|
||||||
|
p = sub.add_parser("comment-create", help="Kommentar auf einen Inhalt (Post/Wiki-Seite) schreiben")
|
||||||
|
p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)")
|
||||||
|
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
|
||||||
|
p.set_defaults(func=cmd_comment_create)
|
||||||
|
|
||||||
|
# mail-send
|
||||||
|
p = sub.add_parser("mail-send", help="Direktnachricht an Benutzer senden")
|
||||||
|
p.add_argument("to", help="Benutzername, Profil-URL oder ID; mehrere mit Komma")
|
||||||
|
p.add_argument("title", help="Betreff")
|
||||||
|
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
|
||||||
|
p.set_defaults(func=cmd_mail_send)
|
||||||
|
|
||||||
|
# mail-reply
|
||||||
|
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("file", help="Markdown-Datei oder '-' für stdin")
|
||||||
|
p.set_defaults(func=cmd_mail_reply)
|
||||||
|
|
||||||
# post-update
|
# post-update
|
||||||
p = sub.add_parser("post-update", help="Post aktualisieren")
|
p = sub.add_parser("post-update", help="Post aktualisieren")
|
||||||
p.add_argument("id", type=int, help="Post-ID")
|
p.add_argument("id", type=int, help="Post-ID")
|
||||||
@@ -253,6 +603,27 @@ def main() -> None:
|
|||||||
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen (ersetzt bestehende)")
|
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen (ersetzt bestehende)")
|
||||||
p.set_defaults(func=cmd_post_update)
|
p.set_defaults(func=cmd_post_update)
|
||||||
|
|
||||||
|
# poll-list
|
||||||
|
p = sub.add_parser("poll-list", help="Umfragen eines Containers auflisten")
|
||||||
|
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
|
||||||
|
p.add_argument("--limit", type=int, default=10, help="Max. Anzahl (Standard: 10)")
|
||||||
|
p.set_defaults(func=cmd_poll_list)
|
||||||
|
|
||||||
|
# poll-create
|
||||||
|
p = sub.add_parser("poll-create", help="Neue Umfrage anlegen")
|
||||||
|
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
|
||||||
|
p.add_argument("question", help="Die Frage (Pflicht)")
|
||||||
|
p.add_argument("--answers", required=True,
|
||||||
|
help="Antwortoptionen, mit | getrennt: 'Ja|Nein|Vielleicht' "
|
||||||
|
"(mindestens zwei)")
|
||||||
|
p.add_argument("--file", default=None,
|
||||||
|
help="Markdown-Datei mit dem Beschreibungstext, '-' für stdin")
|
||||||
|
p.add_argument("--anonymous", action="store_true", help="anonyme Abstimmung")
|
||||||
|
p.add_argument("--multiple", action="store_true", help="Mehrfachauswahl erlauben")
|
||||||
|
p.add_argument("--hide-results", action="store_true",
|
||||||
|
help="Ergebnis erst nach Ende der Umfrage zeigen")
|
||||||
|
p.set_defaults(func=cmd_poll_create)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "klare-sprache",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Texte in klarer, verständlicher Sprache nach ISO 24495-1:2023 (Plain Language) schreiben oder überarbeiten: zielgruppen- und prozessorientiert — finden, verstehen, nutzen.",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: klare-sprache
|
||||||
|
description: Texte in klarer, verständlicher Sprache nach ISO 24495-1:2023 (Plain Language) schreiben oder überarbeiten. Use when the user asks for clear/plain/accessible language, mentions "klare Sprache", "verständlich schreiben", "plain language", "ISO 24495", "bürgernah", "leicht verständlich", "an die Zielgruppe anpassen", or wants a text a specific audience can easily find, understand, and use.
|
||||||
|
version: 0.1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Klare Sprache (ISO 24495-1:2023)
|
||||||
|
|
||||||
|
Dieser Skill schreibt und überarbeitet Texte nach den Leitprinzipien der
|
||||||
|
ISO 24495-1:2023 („Plain language — Part 1"). Ziel: Die vorgesehenen Leser
|
||||||
|
finden leicht, was sie brauchen, verstehen es und können danach handeln.
|
||||||
|
|
||||||
|
Anders als starre Regelwerke (Wortlisten, feste Satzlängen) ist ISO 24495-1
|
||||||
|
**prinzipien- und prozessorientiert**. Der Maßstab ist nicht „einfach an sich",
|
||||||
|
sondern **passend für die konkrete Zielgruppe und ihren Zweck**.
|
||||||
|
|
||||||
|
## Wann anwenden
|
||||||
|
|
||||||
|
Wende den Skill an, wenn der Nutzer einen Text verständlich für eine bestimmte
|
||||||
|
Zielgruppe braucht (Kunden-Doku, Anleitungen, Formulare, Briefe, Web-Texte,
|
||||||
|
Bescheide). Für einen festen, normierten Schreibstil in der internen Technik-
|
||||||
|
Doku passt eher [[einfaches-technisches-deutsch]].
|
||||||
|
|
||||||
|
## Kerndefinition
|
||||||
|
|
||||||
|
Ein Text ist in klarer Sprache, wenn Wortwahl, Struktur und Gestaltung so klar
|
||||||
|
sind, dass die vorgesehenen Leser leicht
|
||||||
|
|
||||||
|
1. **finden**, was sie brauchen,
|
||||||
|
2. **verstehen**, was sie finden, und
|
||||||
|
3. **nutzen** können, was sie verstanden haben.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
1. **Zielgruppe und Zweck klären.** Wer liest? Welches Vorwissen? Was soll die
|
||||||
|
Person danach wissen oder tun? Ist das unklar, frage kurz nach — ohne
|
||||||
|
Zielgruppe lässt sich „klar" nicht beurteilen.
|
||||||
|
2. **Schreiben/Überarbeiten** nach den vier Prinzipien (unten).
|
||||||
|
3. **Prüfen** gegen die Checkliste; wenn möglich, aus Lesersicht gegenlesen.
|
||||||
|
4. **Kurz zurückmelden**, welche Zielgruppe angenommen wurde und was geändert
|
||||||
|
wurde.
|
||||||
|
|
||||||
|
## Die vier Prinzipien mit Checkliste
|
||||||
|
|
||||||
|
### 1. Relevant — Leser bekommen, was sie brauchen
|
||||||
|
- Inhalt an Zielgruppe und Zweck ausrichten.
|
||||||
|
- Vorwissen und Bedarf der Leser berücksichtigen.
|
||||||
|
- Unnötiges weglassen; das Wichtigste zuerst.
|
||||||
|
|
||||||
|
### 2. Auffindbar — Leser finden es leicht
|
||||||
|
- Logische, erwartbare Reihenfolge.
|
||||||
|
- Aussagekräftige Überschriften und Zwischenüberschriften.
|
||||||
|
- Gliederung, Listen, Hervorhebungen; klares Layout und Navigation.
|
||||||
|
|
||||||
|
### 3. Verständlich — Leser verstehen es beim ersten Lesen
|
||||||
|
- Gebräuchliche Wörter; Fachbegriffe und Abkürzungen erklären oder ersetzen.
|
||||||
|
- Kurze, klar gebaute Sätze; ein Gedanke pro Satz.
|
||||||
|
- Aktiv statt Passiv; die Leser direkt ansprechen.
|
||||||
|
- Beispiele, wo sie helfen.
|
||||||
|
|
||||||
|
### 4. Nutzbar — Leser können danach handeln
|
||||||
|
- Handlungsschritte klar und in der richtigen Reihenfolge.
|
||||||
|
- Sagen, wer was tun muss.
|
||||||
|
- Den Text an einer echten Aufgabe prüfen und überarbeiten.
|
||||||
|
|
||||||
|
## Technische Angaben bleiben exakt
|
||||||
|
|
||||||
|
Klarheit betrifft die Sprache, nicht die Fakten. Dateipfade, Befehle,
|
||||||
|
Funktions- und Feldnamen, Preise, Zahlen und Fristen bleiben unverändert.
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
- Der Skill setzt die **Prinzipien** der Norm um; er gibt nicht den geschützten
|
||||||
|
Normtext wieder und bescheinigt keine formale Konformität.
|
||||||
|
- Gilt für Fließtext und Struktur, nicht für Code, Log-Ausgaben oder Zitate.
|
||||||
|
- Ohne bekannte Zielgruppe zuerst nachfragen.
|
||||||
@@ -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,6 +88,7 @@ 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)}")
|
||||||
|
|
||||||
|
try:
|
||||||
result = site.api(
|
result = site.api(
|
||||||
"query", list="search",
|
"query", list="search",
|
||||||
srsearch=args.query,
|
srsearch=args.query,
|
||||||
@@ -95,6 +96,12 @@ def cmd_search(args: argparse.Namespace) -> None:
|
|||||||
srlimit=50,
|
srlimit=50,
|
||||||
srprop="title|snippet",
|
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)")
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "redmine",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "Redmine via REST-API: Projekte/Tickets/Versionen/News auflisten, anlegen, aktualisieren, kommentieren (redmine.py).",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Redmine REST-API — Zugangsdaten (Kopie nach .env, Werte eintragen)
|
||||||
|
# API-Key: https://redmine.inmedias.it/my/account → rechts "API-Zugriffsschlüssel"
|
||||||
|
|
||||||
|
REDMINE_URL=https://redmine.inmedias.it
|
||||||
|
REDMINE_API_KEY=
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
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".
|
||||||
|
version: 0.2.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Redmine (redmine.py)
|
||||||
|
|
||||||
|
Interagiert mit Redmine (https://redmine.inmedias.it) über die REST-API:
|
||||||
|
Projekte, Tickets, Versionen und News auflisten, anzeigen, anlegen,
|
||||||
|
aktualisieren und kommentieren - über die mitgelieferte, self-contained
|
||||||
|
`redmine.py`. Namen werden auf IDs aufgelöst (Projekt, Tracker, Status,
|
||||||
|
Priorität, Version).
|
||||||
|
|
||||||
|
## Ausführen
|
||||||
|
|
||||||
|
`redmine.py` ist ein eigenständiges `uv run --script` (Inline-Deps: `requests`,
|
||||||
|
`python-dotenv`). Aus dem Skill-Verzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py <befehl> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`--json` (vor dem Befehl) gibt jeweils die Rohdaten aus.
|
||||||
|
|
||||||
|
### Kontext & Stammdaten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py me # aktueller Benutzer (id, admin?, mail)
|
||||||
|
./redmine.py projects # sichtbare Projekte (id, identifier, name)
|
||||||
|
./redmine.py meta trackers # Stammdaten: trackers | statuses | priorities
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tickets auflisten / anzeigen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py issues --project pg-wissen # offene Tickets (Default)
|
||||||
|
./redmine.py issues --project pg-wissen --status '*' # alle (open|closed|*|Status-ID)
|
||||||
|
./redmine.py issues --assigned me --sort priority:desc
|
||||||
|
./redmine.py issues --query "Wiki" --limit 20 # Volltext im Betreff (~-Suche)
|
||||||
|
./redmine.py show 290 [--notes] # Ticket (optional mit Journalen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anlegen / Aktualisieren / Kommentieren
|
||||||
|
|
||||||
|
`--project` und `--subject` sind bei `create` Pflicht. Beschreibung inline
|
||||||
|
(`--description`) oder aus Datei/stdin (`--description-file -`) übergeben - nicht
|
||||||
|
als langes Shell-Argument (Sonderzeichen). Weitere Felder: `--tracker`,
|
||||||
|
`--status`, `--priority`, `--assigned me`, `--parent <id>`, `--version <name|id>`,
|
||||||
|
`--done <0-100>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py create --project pg-wissen --subject "Titel" \
|
||||||
|
--tracker Feature --description-file - < body.md
|
||||||
|
./redmine.py update 290 --status Erledigt --done 100 --notes "erledigt"
|
||||||
|
./redmine.py comment 290 "kurzer Kommentar"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versionen (Meilensteine)
|
||||||
|
|
||||||
|
Versionen tragen die grobe Reihenfolge und speisen die Roadmap-Ansicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py versions --project devops # auflisten
|
||||||
|
./redmine.py versions --project devops --name "Phase 1" \
|
||||||
|
--due 2026-10-15 --description "Was in dieser Phase zu tun ist"
|
||||||
|
./redmine.py create --project devops --subject "Schritt" --version "Phase 1"
|
||||||
|
./redmine.py update 350 --version 23 # ohne --project: ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Versionsname lässt sich nur mit `--project` auflösen, weil Versionen zu
|
||||||
|
einem Projekt gehören. Bei `update` ist `--project` meist nicht angegeben -
|
||||||
|
dort die Versions-ID verwenden.
|
||||||
|
|
||||||
|
Anlegen erfordert das Recht *manage_versions*.
|
||||||
|
|
||||||
|
### News (Projektstand, Ankündigungen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./redmine.py news --project devops # auflisten
|
||||||
|
./redmine.py news --project devops --title "Kurzer Titel" \
|
||||||
|
--summary "Eine Zeile für die Übersicht" \
|
||||||
|
--description-file text.md
|
||||||
|
./redmine.py news --id 1 --description-file korrigiert.md # ändern
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Eigenheiten der Redmine-API:
|
||||||
|
|
||||||
|
- **Der Titel darf höchstens 60 Zeichen haben**, sonst HTTP 422. `redmine.py`
|
||||||
|
prüft das vorab.
|
||||||
|
- Die Antwort beim Anlegen enthält **keine News-ID**; der Befehl gibt deshalb
|
||||||
|
die Projekt-URL aus statt eines toten Direktlinks. Ändern per `--id` geht
|
||||||
|
trotzdem (`PUT /news/{id}.json`).
|
||||||
|
|
||||||
|
Format ist Markdown, wie bei Tickets.
|
||||||
|
|
||||||
|
## Zugangsdaten
|
||||||
|
|
||||||
|
`redmine.py` bringt **keinen** Schlüssel mit. Jede/r hinterlegt einen eigenen
|
||||||
|
API-Key als Umgebungsvariable oder in einer dotenv-Datei (Reihenfolge:
|
||||||
|
`~/.config/dokumentierer/.env`, dann Datei im Arbeitsverzeichnis, dann bereits
|
||||||
|
gesetzte Env-Variablen). Siehe `.env.example`:
|
||||||
|
|
||||||
|
- `REDMINE_URL` — Basis-URL (z.B. `https://redmine.inmedias.it`)
|
||||||
|
- `REDMINE_API_KEY` — persönlicher API-Zugriffsschlüssel unter
|
||||||
|
https://redmine.inmedias.it/my/account (rechts „API-Zugriffsschlüssel"). Fehlt
|
||||||
|
der Schlüssel dort, ist der REST-Webservice noch nicht aktiviert
|
||||||
|
(Administration → Konfiguration → API).
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Schreibzugriff nur in Projekten mit „Add/Edit issues"-Recht; ein Nicht-Admin
|
||||||
|
kann keine Tickets in fremden Projekten ändern.
|
||||||
|
- **Ticket-Beziehungen** (`blocks`, `relates`, `precedes`) deckt `redmine.py`
|
||||||
|
nicht ab - dafür `POST /issues/<id>/relations.json`. Ein Ticket kann nicht mit
|
||||||
|
einem eigenen Unterticket verknüpft werden (HTTP 422).
|
||||||
|
- Die Priorität eines Elterntickets leitet Redmine aus den Unteraufgaben ab;
|
||||||
|
ein von Hand gesetzter Wert wird überschrieben. Abschaltbar unter
|
||||||
|
Administration → Konfiguration → „Attribute übergeordneter Tickets".
|
||||||
Executable
+403
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = [
|
||||||
|
# "requests",
|
||||||
|
# "python-dotenv",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""CLI für Redmine via REST-API: Projekte/Tickets auflisten, anlegen, aktualisieren, kommentieren.
|
||||||
|
|
||||||
|
Zugangsdaten aus .env: REDMINE_URL, REDMINE_API_KEY.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
uv run redmine.py me
|
||||||
|
uv run redmine.py projects
|
||||||
|
uv run redmine.py issues --project pg-wissen --status open
|
||||||
|
uv run redmine.py show 42 --notes
|
||||||
|
uv run redmine.py create --project pg-wissen --subject "Titel" --description - < body.md
|
||||||
|
uv run redmine.py update 42 --status Erledigt --notes "gefixt"
|
||||||
|
uv run redmine.py comment 42 "kurzer Kommentar"
|
||||||
|
uv run redmine.py meta trackers
|
||||||
|
uv run redmine.py versions --project devops
|
||||||
|
uv run redmine.py versions --project devops --name "Phase 1" --due 2026-10-15
|
||||||
|
uv run redmine.py create --project devops --subject "Titel" --version "Phase 1"
|
||||||
|
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 --id 1 --description-file korrigiert.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
def session() -> tuple[requests.Session, str]:
|
||||||
|
load_dotenv(Path.home() / ".config/dokumentierer/.env")
|
||||||
|
load_dotenv() # CWD/Projektverzeichnis
|
||||||
|
url = os.environ.get("REDMINE_URL", "").rstrip("/")
|
||||||
|
key = os.environ.get("REDMINE_API_KEY", "")
|
||||||
|
if not url or not key:
|
||||||
|
sys.exit("Fehler: REDMINE_URL und/oder REDMINE_API_KEY fehlen in der .env")
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"X-Redmine-API-Key": key, "Content-Type": "application/json"})
|
||||||
|
s.base = url
|
||||||
|
return s, url
|
||||||
|
|
||||||
|
|
||||||
|
def api(s, method: str, path: str, **kw) -> dict:
|
||||||
|
r = s.request(method, f"{s.base}{path}", **kw)
|
||||||
|
if not r.ok:
|
||||||
|
sys.exit(f"Fehler {r.status_code} bei {method} {path}: {r.text[:400]}")
|
||||||
|
if r.status_code == 204 or not r.content:
|
||||||
|
return {}
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def paged(s, path: str, root: str, params: dict | None = None, cap: int = 1000) -> list:
|
||||||
|
"""Alle Seiten einer Redmine-Collection einsammeln."""
|
||||||
|
params = dict(params or {})
|
||||||
|
out, offset = [], 0
|
||||||
|
while True:
|
||||||
|
params.update({"limit": 100, "offset": offset})
|
||||||
|
d = api(s, "GET", path, params=params)
|
||||||
|
items = d.get(root, [])
|
||||||
|
out += items
|
||||||
|
total = d.get("total_count", len(out))
|
||||||
|
offset += len(items)
|
||||||
|
if not items or offset >= total or len(out) >= cap:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Auflösen von Namen/Identifiern auf IDs -----------------------------------
|
||||||
|
|
||||||
|
def resolve_project(s, value: str) -> int:
|
||||||
|
if value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
for p in paged(s, "/projects.json", "projects"):
|
||||||
|
if value in (p.get("identifier"), p.get("name")):
|
||||||
|
return p["id"]
|
||||||
|
sys.exit(f"Projekt nicht gefunden: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_enum(s, path: str, root: str, value: str, label: str) -> int:
|
||||||
|
if value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
items = api(s, "GET", path).get(root, [])
|
||||||
|
for it in items:
|
||||||
|
if it.get("name", "").lower() == value.lower():
|
||||||
|
return it["id"]
|
||||||
|
names = ", ".join(i.get("name", "") for i in items)
|
||||||
|
sys.exit(f"{label} nicht gefunden: {value} (verfügbar: {names})")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_assigned(s, value: str) -> int:
|
||||||
|
if value in ("me", "@me"):
|
||||||
|
return api(s, "GET", "/users/current.json")["user"]["id"]
|
||||||
|
if value.isdigit():
|
||||||
|
return int(value)
|
||||||
|
sys.exit("--assigned erwartet 'me' oder eine numerische User-ID")
|
||||||
|
|
||||||
|
|
||||||
|
def read_text(value: str | None, file: str | None) -> str | None:
|
||||||
|
if file is not None:
|
||||||
|
return sys.stdin.read() if file == "-" else Path(file).read_text(encoding="utf-8")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Befehle ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_me(s, a):
|
||||||
|
u = api(s, "GET", "/users/current.json")["user"]
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(u, ensure_ascii=False, indent=2))
|
||||||
|
print(f"{u['login']} (id={u['id']}, {u.get('firstname','')} {u.get('lastname','')}, "
|
||||||
|
f"admin={u.get('admin', False)}, mail={u.get('mail','?')})")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_projects(s, a):
|
||||||
|
ps = paged(s, "/projects.json", "projects")
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(ps, ensure_ascii=False, indent=2))
|
||||||
|
for p in sorted(ps, key=lambda x: x["id"]):
|
||||||
|
print(f"#{p['id']:>3} {p['identifier']:<32} {p['name']}")
|
||||||
|
print(f"— {len(ps)} Projekte")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_issues(s, a):
|
||||||
|
params = {"status_id": a.status, "sort": a.sort}
|
||||||
|
if a.project:
|
||||||
|
params["project_id"] = resolve_project(s, a.project)
|
||||||
|
if a.assigned:
|
||||||
|
params["assigned_to_id"] = resolve_assigned(s, a.assigned)
|
||||||
|
if a.query:
|
||||||
|
params["subject"] = "~" + a.query
|
||||||
|
issues = paged(s, "/issues.json", "issues", params, cap=a.limit)[: a.limit]
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(issues, ensure_ascii=False, indent=2))
|
||||||
|
for i in issues:
|
||||||
|
who = (i.get("assigned_to") or {}).get("name", "—")
|
||||||
|
print(f"#{i['id']:>5} [{i['status']['name']:<12}] {i['tracker']['name']:<8} "
|
||||||
|
f"{i['subject'][:70]:<70} ({who})")
|
||||||
|
print(f"— {len(issues)} Tickets")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show(s, a):
|
||||||
|
inc = "journals,attachments,relations,children" if a.notes else "attachments,relations"
|
||||||
|
i = api(s, "GET", f"/issues/{a.id}.json", params={"include": inc})["issue"]
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(i, ensure_ascii=False, indent=2))
|
||||||
|
print(f"#{i['id']} {i['subject']}")
|
||||||
|
print(f" Projekt : {i['project']['name']}")
|
||||||
|
print(f" Status : {i['status']['name']} | Tracker: {i['tracker']['name']} | "
|
||||||
|
f"Priorität: {i['priority']['name']}")
|
||||||
|
print(f" Autor : {i['author']['name']} | Zugewiesen: {(i.get('assigned_to') or {}).get('name','—')}")
|
||||||
|
print(f" Erstellt: {i.get('created_on','')} | Aktualisiert: {i.get('updated_on','')}")
|
||||||
|
if i.get("description"):
|
||||||
|
print(" ---\n" + "\n".join(" " + ln for ln in i["description"].splitlines()))
|
||||||
|
if a.notes:
|
||||||
|
for j in i.get("journals", []):
|
||||||
|
if j.get("notes"):
|
||||||
|
print(f" --- {j['user']['name']} @ {j.get('created_on','')}:\n"
|
||||||
|
+ "\n".join(" " + ln for ln in j["notes"].splitlines()))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_version(s, project, wert: str) -> int:
|
||||||
|
"""Version über Name oder ID auflösen.
|
||||||
|
|
||||||
|
Ein Name lässt sich nur mit `--project` auflösen, weil Versionen zu einem
|
||||||
|
Projekt gehören. Beim Ändern eines Tickets ist `--project` aber meist
|
||||||
|
nicht angegeben - dort ist die Versions-ID anzugeben.
|
||||||
|
"""
|
||||||
|
if str(wert).isdigit():
|
||||||
|
return int(wert)
|
||||||
|
if not project:
|
||||||
|
sys.exit("--version mit Namen braucht --project (oder gib die Versions-ID an)")
|
||||||
|
pid = resolve_project(s, project)
|
||||||
|
for v in api(s, "GET", f"/projects/{pid}/versions.json").get("versions", []):
|
||||||
|
if v["name"].lower() == str(wert).lower():
|
||||||
|
return v["id"]
|
||||||
|
sys.exit(f"Version '{wert}' nicht in Projekt {pid} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_versions(s, a):
|
||||||
|
"""Versionen eines Projekts auflisten oder anlegen."""
|
||||||
|
pid = resolve_project(s, a.project)
|
||||||
|
if a.name:
|
||||||
|
f = {"name": a.name}
|
||||||
|
if a.due:
|
||||||
|
f["due_date"] = a.due
|
||||||
|
if a.description:
|
||||||
|
f["description"] = a.description
|
||||||
|
v = api(s, "POST", f"/projects/{pid}/versions.json",
|
||||||
|
data=json.dumps({"version": f}))["version"]
|
||||||
|
return print(f"Angelegt: Version #{v['id']} {v['name']}")
|
||||||
|
items = api(s, "GET", f"/projects/{pid}/versions.json").get("versions", [])
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(items, ensure_ascii=False, indent=2))
|
||||||
|
if not items:
|
||||||
|
return print("— keine Versionen")
|
||||||
|
for v in items:
|
||||||
|
print(f"#{v['id']:>4} {v['status']:<8} {v.get('due_date') or '—':<12} {v['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_issue_fields(s, a) -> dict:
|
||||||
|
f = {}
|
||||||
|
if getattr(a, "project", None):
|
||||||
|
f["project_id"] = resolve_project(s, a.project)
|
||||||
|
if getattr(a, "subject", None) is not None:
|
||||||
|
f["subject"] = a.subject
|
||||||
|
desc = read_text(getattr(a, "description", None), getattr(a, "description_file", None))
|
||||||
|
if desc is not None:
|
||||||
|
f["description"] = desc
|
||||||
|
if getattr(a, "tracker", None):
|
||||||
|
f["tracker_id"] = resolve_enum(s, "/trackers.json", "trackers", a.tracker, "Tracker")
|
||||||
|
if getattr(a, "status", None):
|
||||||
|
f["status_id"] = resolve_enum(s, "/issue_statuses.json", "issue_statuses", a.status, "Status")
|
||||||
|
if getattr(a, "priority", None):
|
||||||
|
f["priority_id"] = resolve_enum(s, "/enumerations/issue_priorities.json",
|
||||||
|
"issue_priorities", a.priority, "Priorität")
|
||||||
|
if getattr(a, "assigned", None):
|
||||||
|
f["assigned_to_id"] = resolve_assigned(s, a.assigned)
|
||||||
|
if getattr(a, "parent", None):
|
||||||
|
f["parent_issue_id"] = int(a.parent)
|
||||||
|
if getattr(a, "version", None):
|
||||||
|
f["fixed_version_id"] = resolve_version(s, getattr(a, "project", None), a.version)
|
||||||
|
if getattr(a, "done", None) is not None:
|
||||||
|
f["done_ratio"] = int(a.done)
|
||||||
|
if getattr(a, "notes", None):
|
||||||
|
f["notes"] = a.notes
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create(s, a):
|
||||||
|
fields = build_issue_fields(s, a)
|
||||||
|
if "project_id" not in fields or "subject" not in fields:
|
||||||
|
sys.exit("create: --project und --subject sind Pflicht")
|
||||||
|
res = api(s, "POST", "/issues.json", data=json.dumps({"issue": fields}))
|
||||||
|
i = res["issue"]
|
||||||
|
print(f"Angelegt: #{i['id']} {i['subject']} -> {s.base}/issues/{i['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update(s, a):
|
||||||
|
fields = build_issue_fields(s, a)
|
||||||
|
if not fields:
|
||||||
|
sys.exit("update: nichts zu ändern angegeben")
|
||||||
|
api(s, "PUT", f"/issues/{a.id}.json", data=json.dumps({"issue": fields}))
|
||||||
|
print(f"Aktualisiert: #{a.id} ({', '.join(fields)})")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_comment(s, a):
|
||||||
|
api(s, "PUT", f"/issues/{a.id}.json", data=json.dumps({"issue": {"notes": a.text}}))
|
||||||
|
print(f"Kommentar an #{a.id} hinzugefügt.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_news(s, a):
|
||||||
|
"""News lesen, anlegen oder ändern.
|
||||||
|
|
||||||
|
Drei Betriebsarten, unterschieden an den Angaben:
|
||||||
|
ohne --id, ohne --title auflisten
|
||||||
|
--title anlegen (--project ist Pflicht)
|
||||||
|
--id ändern (PUT, antwortet mit 204)
|
||||||
|
|
||||||
|
Der Titel darf höchstens 60 Zeichen haben; darüber antwortet Redmine
|
||||||
|
mit HTTP 422. Geprüft wird das hier schon vorab, damit der Fehler beim
|
||||||
|
Aufruf steht und nicht im Serverprotokoll.
|
||||||
|
"""
|
||||||
|
if getattr(a, "id", None):
|
||||||
|
fields = {}
|
||||||
|
if a.title:
|
||||||
|
fields["title"] = a.title
|
||||||
|
if a.summary is not None:
|
||||||
|
fields["summary"] = a.summary
|
||||||
|
body = read_text(a.description, a.description_file)
|
||||||
|
if body is not None:
|
||||||
|
fields["description"] = body
|
||||||
|
if not fields:
|
||||||
|
sys.exit("news: nichts zu ändern angegeben")
|
||||||
|
if len(fields.get("title", "")) > 60:
|
||||||
|
sys.exit("news: Titel darf höchstens 60 Zeichen haben")
|
||||||
|
api(s, "PUT", f"/news/{a.id}.json", data=json.dumps({"news": fields}))
|
||||||
|
return print(f"Geändert: News #{a.id} ({', '.join(fields)}) -> {s.base}/news/{a.id}")
|
||||||
|
|
||||||
|
if a.title is None:
|
||||||
|
pid = resolve_project(s, a.project) if a.project else None
|
||||||
|
path = f"/projects/{pid}/news.json" if pid else "/news.json"
|
||||||
|
items = api(s, "GET", path, params={"limit": a.limit}).get("news", [])
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(items, ensure_ascii=False, indent=2))
|
||||||
|
if not items:
|
||||||
|
return print("— keine News")
|
||||||
|
for n in items:
|
||||||
|
print(f"#{n['id']:>4} {n.get('created_on', '')[:10]} {n['title']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not a.project:
|
||||||
|
sys.exit("news: zum Anlegen ist --project Pflicht")
|
||||||
|
if len(a.title) > 60:
|
||||||
|
sys.exit("news: Titel darf höchstens 60 Zeichen haben")
|
||||||
|
body = read_text(a.description, a.description_file)
|
||||||
|
if body is None:
|
||||||
|
sys.exit("news: --description oder --description-file angeben")
|
||||||
|
pid = resolve_project(s, a.project)
|
||||||
|
fields = {"title": a.title, "description": body}
|
||||||
|
if a.summary:
|
||||||
|
fields["summary"] = a.summary
|
||||||
|
res = api(s, "POST", f"/projects/{pid}/news.json", data=json.dumps({"news": fields}))
|
||||||
|
n = res.get("news", {})
|
||||||
|
if n.get("id"):
|
||||||
|
print(f"Angelegt: News #{n['id']} {n.get('title', '')} -> {s.base}/news/{n['id']}")
|
||||||
|
else:
|
||||||
|
print(f"Angelegt in Projekt {pid} -> {s.base}/projects/{pid}/news")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_meta(s, a):
|
||||||
|
spec = {
|
||||||
|
"trackers": ("/trackers.json", "trackers"),
|
||||||
|
"statuses": ("/issue_statuses.json", "issue_statuses"),
|
||||||
|
"priorities": ("/enumerations/issue_priorities.json", "issue_priorities"),
|
||||||
|
}[a.kind]
|
||||||
|
items = api(s, "GET", spec[0]).get(spec[1], [])
|
||||||
|
if a.json:
|
||||||
|
return print(json.dumps(items, ensure_ascii=False, indent=2))
|
||||||
|
for it in items:
|
||||||
|
extra = " (default)" if it.get("is_default") else ""
|
||||||
|
print(f"#{it['id']:>3} {it['name']}{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Redmine REST-API CLI")
|
||||||
|
p.add_argument("--json", action="store_true", help="Rohdaten als JSON ausgeben")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("me", help="Aktuellen Benutzer anzeigen")
|
||||||
|
sub.add_parser("projects", help="Projekte auflisten")
|
||||||
|
|
||||||
|
pi = sub.add_parser("issues", help="Tickets auflisten")
|
||||||
|
pi.add_argument("--project", help="Projekt (ID, Identifier oder Name)")
|
||||||
|
pi.add_argument("--status", default="open", help="open (Default) | closed | * | Status-ID")
|
||||||
|
pi.add_argument("--assigned", help="me | User-ID")
|
||||||
|
pi.add_argument("--query", help="Volltext im Betreff (~-Suche)")
|
||||||
|
pi.add_argument("--sort", default="updated_on:desc", help="z.B. updated_on:desc, priority:desc")
|
||||||
|
pi.add_argument("--limit", type=int, default=50)
|
||||||
|
|
||||||
|
ps = sub.add_parser("show", help="Ticket anzeigen")
|
||||||
|
ps.add_argument("id")
|
||||||
|
ps.add_argument("--notes", action="store_true", help="Journale/Kommentare mit anzeigen")
|
||||||
|
|
||||||
|
for name, help_ in (("create", "Ticket anlegen"), ("update", "Ticket aktualisieren")):
|
||||||
|
c = sub.add_parser(name, help=help_)
|
||||||
|
if name == "update":
|
||||||
|
c.add_argument("id")
|
||||||
|
c.add_argument("--project", help="Projekt (ID/Identifier/Name)" + (" [Pflicht]" if name == "create" else ""))
|
||||||
|
c.add_argument("--subject")
|
||||||
|
c.add_argument("--description", help="Beschreibungstext")
|
||||||
|
c.add_argument("--description-file", help="Datei oder '-' für stdin")
|
||||||
|
c.add_argument("--tracker", help="Tracker (Name oder ID)")
|
||||||
|
c.add_argument("--status", help="Status (Name oder ID)")
|
||||||
|
c.add_argument("--priority", help="Priorität (Name oder ID)")
|
||||||
|
c.add_argument("--assigned", help="me | User-ID")
|
||||||
|
c.add_argument("--parent", help="übergeordnetes Ticket (ID)")
|
||||||
|
c.add_argument("--version", help="Zielversion (Name oder ID)")
|
||||||
|
c.add_argument("--done", type=int, help="Fortschritt in %% (0-100)")
|
||||||
|
if name == "update":
|
||||||
|
c.add_argument("--notes", help="Kommentar zur Änderung")
|
||||||
|
|
||||||
|
pc = sub.add_parser("comment", help="Kommentar an ein Ticket hängen")
|
||||||
|
pc.add_argument("id"); pc.add_argument("text")
|
||||||
|
|
||||||
|
pn = sub.add_parser("news", help="News lesen, anlegen oder ändern")
|
||||||
|
pn.add_argument("--project", help="Projekt (ID/Identifier/Name); zum Anlegen Pflicht")
|
||||||
|
pn.add_argument("--id", help="News-ID - gesetzt heißt: ändern")
|
||||||
|
pn.add_argument("--title", help="Titel (max. 60 Zeichen); gesetzt heißt: anlegen statt auflisten")
|
||||||
|
pn.add_argument("--summary", help="Kurzfassung (optional, erscheint in der Übersicht)")
|
||||||
|
pn.add_argument("--description", help="Text")
|
||||||
|
pn.add_argument("--description-file", help="Datei oder '-' für stdin")
|
||||||
|
pn.add_argument("--limit", type=int, default=25)
|
||||||
|
|
||||||
|
pv = sub.add_parser("versions", help="Versionen auflisten oder anlegen")
|
||||||
|
pv.add_argument("--project", required=True, help="Projekt (ID/Identifier/Name)")
|
||||||
|
pv.add_argument("--name", help="Name - gesetzt heißt: anlegen statt auflisten")
|
||||||
|
pv.add_argument("--due", help="Fälligkeitsdatum (YYYY-MM-DD)")
|
||||||
|
pv.add_argument("--description", help="Beschreibung")
|
||||||
|
|
||||||
|
pm = sub.add_parser("meta", help="Stammdaten auflisten")
|
||||||
|
pm.add_argument("kind", choices=["trackers", "statuses", "priorities"])
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
s, _ = session()
|
||||||
|
{
|
||||||
|
"me": cmd_me, "projects": cmd_projects, "issues": cmd_issues, "show": cmd_show,
|
||||||
|
"create": cmd_create, "update": cmd_update, "comment": cmd_comment,
|
||||||
|
"news": cmd_news, "versions": cmd_versions, "meta": cmd_meta,
|
||||||
|
}[a.cmd](s, a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user