lxw-cli 0.1.3__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/PKG-INFO +65 -4
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/README.md +63 -3
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/pyproject.toml +3 -1
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/client.py +4 -2
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/services.py +347 -0
- lxw_cli-0.4.0/src/lxw_cli/feature_request.py +57 -0
- lxw_cli-0.4.0/src/lxw_cli/mcp_auth.py +586 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/mcp_server.py +223 -20
- lxw_cli-0.4.0/tests/test_feature_request.py +32 -0
- lxw_cli-0.4.0/tests/test_mcp_auth.py +358 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_mcp_server.py +18 -1
- lxw_cli-0.4.0/tests/test_services.py +498 -0
- lxw_cli-0.1.3/tests/test_services.py +0 -257
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.env.example +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.gitattributes +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.github/workflows/test.yml +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/.gitignore +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/LICENSE +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/docs/tui-screenshot.svg +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/__init__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/__main__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/cli.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/__init__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/_common.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/articles.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/contacts.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/delivery_notes.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/invoices.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/mcp.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/orders.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/quotations.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/commands/vouchers.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/config.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/__init__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/constants.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/errors.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/core/models.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/output.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/__init__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/app.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/detail.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/entities.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/src/lxw_cli/tui/order_form.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/__init__.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/conftest.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/contacts_flat.txt +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/contacts_grouped.txt +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/snapshots/invoices_list.txt +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_cli.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_cli_snapshots.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_client.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_config.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_output.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_resolve.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/tests/test_tui.py +0 -0
- {lxw_cli-0.1.3 → lxw_cli-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lxw-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)
|
|
5
5
|
Project-URL: Homepage, https://github.com/OEM-Mrks/lxw-cli
|
|
6
6
|
Project-URL: Documentation, https://developers.lexware.io/
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: cryptography>=42
|
|
20
21
|
Requires-Dist: fastmcp>=2.10
|
|
21
22
|
Requires-Dist: httpx>=0.27
|
|
22
23
|
Requires-Dist: pydantic>=2.6
|
|
@@ -97,10 +98,11 @@ lxw --version # Kontrolle
|
|
|
97
98
|
```
|
|
98
99
|
|
|
99
100
|
Sieht uv eine frisch veröffentlichte Version noch nicht (das PyPI-CDN braucht
|
|
100
|
-
nach einem Release ein paar Minuten)
|
|
101
|
+
nach einem Release ein paar Minuten) oder ist das Tool auf eine exakte Version
|
|
102
|
+
gepinnt, hilft ein Neu-Install auf `latest`:
|
|
101
103
|
|
|
102
104
|
```bash
|
|
103
|
-
uv tool
|
|
105
|
+
uv tool install --force lxw-cli@latest
|
|
104
106
|
```
|
|
105
107
|
|
|
106
108
|
Der MCP-Server für Claude nutzt das Update automatisch mit — bereits laufende
|
|
@@ -405,11 +407,21 @@ Claude erkennt die Tools automatisch und ruft sie auf.
|
|
|
405
407
|
|
|
406
408
|
### Verfügbare MCP-Tools
|
|
407
409
|
|
|
408
|
-
|
|
410
|
+
31 Tools mit identischem Verhalten wie die CLI-Befehle:
|
|
409
411
|
|
|
412
|
+
- **Info**: `version` (meldet die laufende Version + Build-Zeitstempel — nützlich, wenn eine Client-UI wie ChatGPT eine gecachte/alte Version anzeigt)
|
|
413
|
+
- **Belegkette (weiterführen)**: `continue_document` führt einen Beleg in den Folgebeleg fort und hält beide verknüpft (`precedingSalesVoucherId`). Der Quelltyp wird automatisch erkannt (Belegnummer oder id). Unterstützte Wege (wie in Lexware):
|
|
414
|
+
- **Angebot** → Auftrag · Lieferschein · Rechnung
|
|
415
|
+
- **Auftrag** → Lieferschein · Rechnung
|
|
416
|
+
- **Lieferschein** → Rechnung
|
|
417
|
+
- **Rechnung** → Lieferschein · Rechnungskorrektur
|
|
418
|
+
|
|
419
|
+
Inhalte (Kunde, Positionen, Texte) werden übernommen, der Folgebeleg entsteht als Entwurf. Für Rechnung/Rechnungskorrektur muss der Ausgangsbeleg **festgeschrieben** sein (sonst klare Meldung statt HTTP 406). **Abschlagsrechnung** und **Serienrechnung** sind über die Lexware-API nicht erstellbar — das meldet das Tool klar.
|
|
410
420
|
- **Lesen**: `profile`, `list_invoices`, `get_invoice`, `download_invoice_pdf`, `list_contacts`, `get_contact`, `list_vouchers`, `get_voucher`, `list_articles`, `get_article`, `list_quotations`, `get_quotation`, `download_quotation_pdf`, `list_order_confirmations`, `get_order_confirmation`, `download_order_confirmation_pdf`, `list_delivery_notes`, `get_delivery_note`, `download_delivery_note_pdf`
|
|
411
421
|
- **Belege als Draft anlegen**: `create_invoice_draft`, `create_voucher_draft`, `create_quotation_draft`, `create_order_confirmation_draft`, `create_delivery_note_draft`
|
|
412
422
|
- **Stammdaten anlegen** (kein Draft): `create_contact`, `create_article`
|
|
423
|
+
- **Stammdaten bearbeiten** (Teil-Update mit Auto-Merge + automatischem `version`-Handling): `update_contact`, `update_article`. Es werden nur die übergebenen Felder geändert; verschachtelte Objekte werden feldweise zusammengeführt, Listen komplett ersetzt. (Kontakt-`archived` ist in der Lexware-API read-only und kann nicht per API gesetzt werden. Firmen-Kontakte mit **mehr als einem Ansprechpartner** lassen sich per Lexware-API grundsätzlich nicht aktualisieren — `update_contact` meldet das klar, statt eines rohen HTTP 406.)
|
|
424
|
+
- **Funktionswunsch melden**: `request_feature` erstellt einen **unverbindlichen** Feature-Wunsch als fertigen Text (`subject`/`body`) samt Anbieter-Adresse (`to` = david@oemedia.de). Der Server **versendet nichts** — der Nutzer kopiert den Text und schickt ihn selbst per E-Mail. Dies ist ein Endkundenprodukt: der Assistent baut keine Funktionen selbst, sondern bietet bei fehlenden Funktionen nur diesen Weg an.
|
|
413
425
|
|
|
414
426
|
Alle document-Tools (`get_*`, `download_*_pdf`) akzeptieren UUID **oder** Belegnummer (z.B. `FB2600682`). PDFs landen in `~/Downloads/lexware/`.
|
|
415
427
|
|
|
@@ -431,6 +443,55 @@ lxw mcp uninstall-desktop # aus Claude Desktop entfernen
|
|
|
431
443
|
lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
|
|
432
444
|
```
|
|
433
445
|
|
|
446
|
+
### Multi-User-Server (HTTP) — ein Server, jeder mit eigenem Key
|
|
447
|
+
|
|
448
|
+
Neben dem lokalen stdio-Modus gibt es `lxw-mcp-http`: einen zentralen
|
|
449
|
+
MCP-Server für mehrere Nutzer, bei dem **jeder seinen eigenen Lexware-API-Key
|
|
450
|
+
mitbringt**. Der Server speichert keine Keys — er ist ein zustandsloser Proxy.
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
LXW_MCP_PUBLIC_URL=https://mcp.example.com \
|
|
454
|
+
LXW_MCP_SECRET='ein-langes-zufaelliges-secret' \
|
|
455
|
+
lxw-mcp-http # lauscht auf 127.0.0.1:8788 (LXW_MCP_HOST/LXW_MCP_PORT)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Zwei Wege, den eigenen Key mitzubringen:
|
|
459
|
+
|
|
460
|
+
1. **Direkt als Bearer-Header** (Claude Code, Cursor, Skripte — alles, was
|
|
461
|
+
eigene Header kann). Der Key bleibt in der lokalen Client-Config:
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
claude mcp add lexware --transport http https://mcp.example.com/mcp \
|
|
465
|
+
--header "Authorization: Bearer ${LEXWARE_API_KEY}"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
2. **OAuth** (claude.ai, ChatGPT — Clients ohne Header-Support): Beim
|
|
469
|
+
Verbinden öffnet sich eine Consent-Seite des Servers, auf der man einmalig
|
|
470
|
+
seinen Lexware-Key eingibt. Der Server prüft ihn gegen die Lexware-API und
|
|
471
|
+
stellt ein Zugriffstoken aus, in dem der Key **verschlüsselt eingebettet**
|
|
472
|
+
ist (Fernet, `LXW_MCP_SECRET`). Das Token liegt beim Client auf dem Gerät
|
|
473
|
+
des Nutzers; der Server entschlüsselt es nur pro Request und persistiert
|
|
474
|
+
ausschließlich die OAuth-Client-Registrierungen (`LXW_MCP_DATA_DIR`,
|
|
475
|
+
Standard `~/.config/lexware/mcp/`).
|
|
476
|
+
|
|
477
|
+
Hinweise für den Betrieb:
|
|
478
|
+
|
|
479
|
+
- `LXW_MCP_SECRET` **fest setzen** — ohne festes Secret werden alle Tokens
|
|
480
|
+
bei jedem Neustart ungültig. Rotieren des Secrets invalidiert bewusst alle
|
|
481
|
+
ausgestellten Tokens.
|
|
482
|
+
- Vor den Server gehört ein HTTPS-Reverse-Proxy (Caddy, nginx, Cloudflare
|
|
483
|
+
Tunnel, `tailscale serve`, …); `LXW_MCP_PUBLIC_URL` ist die von außen
|
|
484
|
+
sichtbare Basis-URL (sie steht in den OAuth-Metadaten).
|
|
485
|
+
- PDF-Downloads liefern über HTTP das PDF als Binärinhalt zurück (statt eines
|
|
486
|
+
Pfads auf der Server-Platte wie im stdio-Modus).
|
|
487
|
+
- Tokens laufen nach 24 h ab; Clients erneuern sie automatisch per
|
|
488
|
+
Refresh-Token. Ein geleaktes Token wird wirkungslos, sobald der zugehörige
|
|
489
|
+
Lexware-Key unter <https://app.lexware.de/addons/public-api> widerrufen wird.
|
|
490
|
+
|
|
491
|
+
`request_feature` braucht keine Konfiguration: es liefert nur einen fertigen
|
|
492
|
+
Text + die Empfänger-Adresse (`david@oemedia.de`) zurück, den der Nutzer selbst
|
|
493
|
+
per E-Mail sendet. Der Server verschickt nichts.
|
|
494
|
+
|
|
434
495
|
## Out of Scope (v1)
|
|
435
496
|
|
|
436
497
|
Bewusst nicht enthalten:
|
|
@@ -62,10 +62,11 @@ lxw --version # Kontrolle
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
Sieht uv eine frisch veröffentlichte Version noch nicht (das PyPI-CDN braucht
|
|
65
|
-
nach einem Release ein paar Minuten)
|
|
65
|
+
nach einem Release ein paar Minuten) oder ist das Tool auf eine exakte Version
|
|
66
|
+
gepinnt, hilft ein Neu-Install auf `latest`:
|
|
66
67
|
|
|
67
68
|
```bash
|
|
68
|
-
uv tool
|
|
69
|
+
uv tool install --force lxw-cli@latest
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
Der MCP-Server für Claude nutzt das Update automatisch mit — bereits laufende
|
|
@@ -370,11 +371,21 @@ Claude erkennt die Tools automatisch und ruft sie auf.
|
|
|
370
371
|
|
|
371
372
|
### Verfügbare MCP-Tools
|
|
372
373
|
|
|
373
|
-
|
|
374
|
+
31 Tools mit identischem Verhalten wie die CLI-Befehle:
|
|
374
375
|
|
|
376
|
+
- **Info**: `version` (meldet die laufende Version + Build-Zeitstempel — nützlich, wenn eine Client-UI wie ChatGPT eine gecachte/alte Version anzeigt)
|
|
377
|
+
- **Belegkette (weiterführen)**: `continue_document` führt einen Beleg in den Folgebeleg fort und hält beide verknüpft (`precedingSalesVoucherId`). Der Quelltyp wird automatisch erkannt (Belegnummer oder id). Unterstützte Wege (wie in Lexware):
|
|
378
|
+
- **Angebot** → Auftrag · Lieferschein · Rechnung
|
|
379
|
+
- **Auftrag** → Lieferschein · Rechnung
|
|
380
|
+
- **Lieferschein** → Rechnung
|
|
381
|
+
- **Rechnung** → Lieferschein · Rechnungskorrektur
|
|
382
|
+
|
|
383
|
+
Inhalte (Kunde, Positionen, Texte) werden übernommen, der Folgebeleg entsteht als Entwurf. Für Rechnung/Rechnungskorrektur muss der Ausgangsbeleg **festgeschrieben** sein (sonst klare Meldung statt HTTP 406). **Abschlagsrechnung** und **Serienrechnung** sind über die Lexware-API nicht erstellbar — das meldet das Tool klar.
|
|
375
384
|
- **Lesen**: `profile`, `list_invoices`, `get_invoice`, `download_invoice_pdf`, `list_contacts`, `get_contact`, `list_vouchers`, `get_voucher`, `list_articles`, `get_article`, `list_quotations`, `get_quotation`, `download_quotation_pdf`, `list_order_confirmations`, `get_order_confirmation`, `download_order_confirmation_pdf`, `list_delivery_notes`, `get_delivery_note`, `download_delivery_note_pdf`
|
|
376
385
|
- **Belege als Draft anlegen**: `create_invoice_draft`, `create_voucher_draft`, `create_quotation_draft`, `create_order_confirmation_draft`, `create_delivery_note_draft`
|
|
377
386
|
- **Stammdaten anlegen** (kein Draft): `create_contact`, `create_article`
|
|
387
|
+
- **Stammdaten bearbeiten** (Teil-Update mit Auto-Merge + automatischem `version`-Handling): `update_contact`, `update_article`. Es werden nur die übergebenen Felder geändert; verschachtelte Objekte werden feldweise zusammengeführt, Listen komplett ersetzt. (Kontakt-`archived` ist in der Lexware-API read-only und kann nicht per API gesetzt werden. Firmen-Kontakte mit **mehr als einem Ansprechpartner** lassen sich per Lexware-API grundsätzlich nicht aktualisieren — `update_contact` meldet das klar, statt eines rohen HTTP 406.)
|
|
388
|
+
- **Funktionswunsch melden**: `request_feature` erstellt einen **unverbindlichen** Feature-Wunsch als fertigen Text (`subject`/`body`) samt Anbieter-Adresse (`to` = david@oemedia.de). Der Server **versendet nichts** — der Nutzer kopiert den Text und schickt ihn selbst per E-Mail. Dies ist ein Endkundenprodukt: der Assistent baut keine Funktionen selbst, sondern bietet bei fehlenden Funktionen nur diesen Weg an.
|
|
378
389
|
|
|
379
390
|
Alle document-Tools (`get_*`, `download_*_pdf`) akzeptieren UUID **oder** Belegnummer (z.B. `FB2600682`). PDFs landen in `~/Downloads/lexware/`.
|
|
380
391
|
|
|
@@ -396,6 +407,55 @@ lxw mcp uninstall-desktop # aus Claude Desktop entfernen
|
|
|
396
407
|
lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
|
|
397
408
|
```
|
|
398
409
|
|
|
410
|
+
### Multi-User-Server (HTTP) — ein Server, jeder mit eigenem Key
|
|
411
|
+
|
|
412
|
+
Neben dem lokalen stdio-Modus gibt es `lxw-mcp-http`: einen zentralen
|
|
413
|
+
MCP-Server für mehrere Nutzer, bei dem **jeder seinen eigenen Lexware-API-Key
|
|
414
|
+
mitbringt**. Der Server speichert keine Keys — er ist ein zustandsloser Proxy.
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
LXW_MCP_PUBLIC_URL=https://mcp.example.com \
|
|
418
|
+
LXW_MCP_SECRET='ein-langes-zufaelliges-secret' \
|
|
419
|
+
lxw-mcp-http # lauscht auf 127.0.0.1:8788 (LXW_MCP_HOST/LXW_MCP_PORT)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Zwei Wege, den eigenen Key mitzubringen:
|
|
423
|
+
|
|
424
|
+
1. **Direkt als Bearer-Header** (Claude Code, Cursor, Skripte — alles, was
|
|
425
|
+
eigene Header kann). Der Key bleibt in der lokalen Client-Config:
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
claude mcp add lexware --transport http https://mcp.example.com/mcp \
|
|
429
|
+
--header "Authorization: Bearer ${LEXWARE_API_KEY}"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
2. **OAuth** (claude.ai, ChatGPT — Clients ohne Header-Support): Beim
|
|
433
|
+
Verbinden öffnet sich eine Consent-Seite des Servers, auf der man einmalig
|
|
434
|
+
seinen Lexware-Key eingibt. Der Server prüft ihn gegen die Lexware-API und
|
|
435
|
+
stellt ein Zugriffstoken aus, in dem der Key **verschlüsselt eingebettet**
|
|
436
|
+
ist (Fernet, `LXW_MCP_SECRET`). Das Token liegt beim Client auf dem Gerät
|
|
437
|
+
des Nutzers; der Server entschlüsselt es nur pro Request und persistiert
|
|
438
|
+
ausschließlich die OAuth-Client-Registrierungen (`LXW_MCP_DATA_DIR`,
|
|
439
|
+
Standard `~/.config/lexware/mcp/`).
|
|
440
|
+
|
|
441
|
+
Hinweise für den Betrieb:
|
|
442
|
+
|
|
443
|
+
- `LXW_MCP_SECRET` **fest setzen** — ohne festes Secret werden alle Tokens
|
|
444
|
+
bei jedem Neustart ungültig. Rotieren des Secrets invalidiert bewusst alle
|
|
445
|
+
ausgestellten Tokens.
|
|
446
|
+
- Vor den Server gehört ein HTTPS-Reverse-Proxy (Caddy, nginx, Cloudflare
|
|
447
|
+
Tunnel, `tailscale serve`, …); `LXW_MCP_PUBLIC_URL` ist die von außen
|
|
448
|
+
sichtbare Basis-URL (sie steht in den OAuth-Metadaten).
|
|
449
|
+
- PDF-Downloads liefern über HTTP das PDF als Binärinhalt zurück (statt eines
|
|
450
|
+
Pfads auf der Server-Platte wie im stdio-Modus).
|
|
451
|
+
- Tokens laufen nach 24 h ab; Clients erneuern sie automatisch per
|
|
452
|
+
Refresh-Token. Ein geleaktes Token wird wirkungslos, sobald der zugehörige
|
|
453
|
+
Lexware-Key unter <https://app.lexware.de/addons/public-api> widerrufen wird.
|
|
454
|
+
|
|
455
|
+
`request_feature` braucht keine Konfiguration: es liefert nur einen fertigen
|
|
456
|
+
Text + die Empfänger-Adresse (`david@oemedia.de`) zurück, den der Nutzer selbst
|
|
457
|
+
per E-Mail sendet. Der Server verschickt nichts.
|
|
458
|
+
|
|
399
459
|
## Out of Scope (v1)
|
|
400
460
|
|
|
401
461
|
Bewusst nicht enthalten:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lxw-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
|
30
30
|
"python-dotenv>=1.0",
|
|
31
31
|
"fastmcp>=2.10",
|
|
32
32
|
"textual>=0.58",
|
|
33
|
+
"cryptography>=42",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[project.optional-dependencies]
|
|
@@ -44,6 +45,7 @@ dev = [
|
|
|
44
45
|
[project.scripts]
|
|
45
46
|
lxw = "lxw_cli.cli:_run"
|
|
46
47
|
lxw-mcp = "lxw_cli.mcp_server:run"
|
|
48
|
+
lxw-mcp-http = "lxw_cli.mcp_server:run_http"
|
|
47
49
|
lxw-tui = "lxw_cli.tui.app:run"
|
|
48
50
|
|
|
49
51
|
[project.urls]
|
|
@@ -125,8 +125,10 @@ class LexwareClient:
|
|
|
125
125
|
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
126
126
|
return self._request("GET", path, params=params).json()
|
|
127
127
|
|
|
128
|
-
def post(
|
|
129
|
-
|
|
128
|
+
def post(
|
|
129
|
+
self, path: str, json_body: Any, params: dict[str, Any] | None = None
|
|
130
|
+
) -> Any:
|
|
131
|
+
return self._request("POST", path, params=params, json=json_body).json()
|
|
130
132
|
|
|
131
133
|
def put(self, path: str, json_body: Any) -> Any:
|
|
132
134
|
return self._request("PUT", path, json=json_body).json()
|
|
@@ -500,6 +500,263 @@ def create_delivery_note(
|
|
|
500
500
|
return client.post("/v1/delivery-notes", body)
|
|
501
501
|
|
|
502
502
|
|
|
503
|
+
# -- Belegkette: einen Beleg in den Folgebeleg fortführen (pursue) -----------
|
|
504
|
+
|
|
505
|
+
# Human labels + the resource endpoint per document type.
|
|
506
|
+
_DOC_META: dict[str, dict[str, str]] = {
|
|
507
|
+
"quotation": {"label": "Angebot", "path": "/v1/quotations/{id}"},
|
|
508
|
+
"orderconfirmation": {"label": "Auftrag", "path": "/v1/order-confirmations/{id}"},
|
|
509
|
+
"deliverynote": {"label": "Lieferschein", "path": "/v1/delivery-notes/{id}"},
|
|
510
|
+
"invoice": {"label": "Rechnung", "path": "/v1/invoices/{id}"},
|
|
511
|
+
"creditnote": {"label": "Rechnungskorrektur", "path": "/v1/credit-notes/{id}"},
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
# The follow-up transitions Lexware's API supports (mirrors the web app's
|
|
515
|
+
# "Weiterführen" menu). Nested: source type -> target type -> create endpoint.
|
|
516
|
+
# Abschlagsrechnung (down payment) and Serienrechnung are intentionally absent —
|
|
517
|
+
# the API cannot create those (down payment invoices are read-only).
|
|
518
|
+
_PURSUE_MATRIX: dict[str, dict[str, str]] = {
|
|
519
|
+
"quotation": {
|
|
520
|
+
"orderconfirmation": "/v1/order-confirmations",
|
|
521
|
+
"deliverynote": "/v1/delivery-notes",
|
|
522
|
+
"invoice": "/v1/invoices",
|
|
523
|
+
},
|
|
524
|
+
"orderconfirmation": {
|
|
525
|
+
"deliverynote": "/v1/delivery-notes",
|
|
526
|
+
"invoice": "/v1/invoices",
|
|
527
|
+
},
|
|
528
|
+
"deliverynote": {
|
|
529
|
+
"invoice": "/v1/invoices",
|
|
530
|
+
},
|
|
531
|
+
"invoice": {
|
|
532
|
+
"deliverynote": "/v1/delivery-notes",
|
|
533
|
+
"creditnote": "/v1/credit-notes",
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# Map human/German synonyms (and API type strings) to a normalized target type.
|
|
538
|
+
_TARGET_ALIASES = {
|
|
539
|
+
"auftrag": "orderconfirmation",
|
|
540
|
+
"auftragsbestätigung": "orderconfirmation",
|
|
541
|
+
"auftragsbestaetigung": "orderconfirmation",
|
|
542
|
+
"orderconfirmation": "orderconfirmation",
|
|
543
|
+
"order_confirmation": "orderconfirmation",
|
|
544
|
+
"order-confirmation": "orderconfirmation",
|
|
545
|
+
"lieferschein": "deliverynote",
|
|
546
|
+
"deliverynote": "deliverynote",
|
|
547
|
+
"delivery_note": "deliverynote",
|
|
548
|
+
"rechnung": "invoice",
|
|
549
|
+
"invoice": "invoice",
|
|
550
|
+
"rechnungskorrektur": "creditnote",
|
|
551
|
+
"gutschrift": "creditnote",
|
|
552
|
+
"korrektur": "creditnote",
|
|
553
|
+
"creditnote": "creditnote",
|
|
554
|
+
"credit_note": "creditnote",
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
# Targets a user may ask for that the API cannot create (only the web app can).
|
|
558
|
+
_TARGET_UNSUPPORTED = {
|
|
559
|
+
"abschlagsrechnung": "Abschlagsrechnung",
|
|
560
|
+
"downpaymentinvoice": "Abschlagsrechnung",
|
|
561
|
+
"down_payment_invoice": "Abschlagsrechnung",
|
|
562
|
+
"serienrechnung": "Serienrechnung",
|
|
563
|
+
"recurring": "Serienrechnung",
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# voucherlist reports finalized invoices as "salesinvoice"; normalize to our key.
|
|
567
|
+
_SOURCE_TYPE_NORMALIZE = {"salesinvoice": "invoice"}
|
|
568
|
+
|
|
569
|
+
# Server-managed / computed fields, plus type-specific fields that must not be
|
|
570
|
+
# carried into the follow-up (e.g. a quotation's expirationDate has no place on
|
|
571
|
+
# an order confirmation or invoice).
|
|
572
|
+
_PURSUE_DROP_DOC = {
|
|
573
|
+
"id",
|
|
574
|
+
"organizationId",
|
|
575
|
+
"version",
|
|
576
|
+
"voucherNumber",
|
|
577
|
+
"voucherStatus",
|
|
578
|
+
"createdDate",
|
|
579
|
+
"updatedDate",
|
|
580
|
+
"files",
|
|
581
|
+
"relatedVouchers",
|
|
582
|
+
"archived",
|
|
583
|
+
"taxAmounts",
|
|
584
|
+
"expirationDate",
|
|
585
|
+
"electronicDocumentProfile",
|
|
586
|
+
}
|
|
587
|
+
_PURSUE_DROP_LINEITEM = {"id", "lineItemAmount"}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def continue_document(
|
|
591
|
+
client: LexwareClient, identifier: str, target: str
|
|
592
|
+
) -> dict[str, Any]:
|
|
593
|
+
"""Continue a sales voucher into its follow-up, preserving the chain.
|
|
594
|
+
|
|
595
|
+
Detects the source document's type from `identifier` (UUID or voucher
|
|
596
|
+
number), copies its content into a fresh draft of `target`, and POSTs it
|
|
597
|
+
with ``precedingSalesVoucherId`` so Lexware links both in the Belegkette.
|
|
598
|
+
Supported transitions mirror the Lexware web app (Angebot→Auftrag/
|
|
599
|
+
Lieferschein/Rechnung, Auftrag→Lieferschein/Rechnung, Lieferschein→Rechnung,
|
|
600
|
+
Rechnung→Lieferschein/Rechnungskorrektur). Invalid or unsupported steps are
|
|
601
|
+
reported clearly instead of as a raw HTTP 406.
|
|
602
|
+
"""
|
|
603
|
+
raw = target.strip().lower()
|
|
604
|
+
if raw in _TARGET_UNSUPPORTED:
|
|
605
|
+
name = _TARGET_UNSUPPORTED[raw]
|
|
606
|
+
raise LexwareError(
|
|
607
|
+
f"{name} kann über die Schnittstelle nicht erstellt werden — das geht "
|
|
608
|
+
"nur direkt in Lexware."
|
|
609
|
+
)
|
|
610
|
+
target_type = _TARGET_ALIASES.get(raw)
|
|
611
|
+
if target_type is None:
|
|
612
|
+
raise LexwareError(
|
|
613
|
+
f"Unbekanntes Ziel '{target}'. Möglich sind: Auftrag, Lieferschein, "
|
|
614
|
+
"Rechnung oder Rechnungskorrektur."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
source_type, source_id, source = _resolve_source(client, identifier)
|
|
618
|
+
allowed = _PURSUE_MATRIX.get(source_type, {})
|
|
619
|
+
target_path = allowed.get(target_type)
|
|
620
|
+
if target_path is None:
|
|
621
|
+
from_label = _DOC_META.get(source_type, {}).get("label", source_type)
|
|
622
|
+
options = ", ".join(_DOC_META[t]["label"] for t in allowed) or "keine"
|
|
623
|
+
raise LexwareError(
|
|
624
|
+
f"Ein {from_label} kann nicht zu '{_DOC_META[target_type]['label']}' "
|
|
625
|
+
f"weitergeführt werden. Von hier möglich: {options}."
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
body = _pursue_body(source)
|
|
629
|
+
try:
|
|
630
|
+
return client.post(
|
|
631
|
+
target_path, body, params={"precedingSalesVoucherId": source_id}
|
|
632
|
+
)
|
|
633
|
+
except LexwareAPIError as exc:
|
|
634
|
+
if exc.status_code in (400, 406):
|
|
635
|
+
raise LexwareError(
|
|
636
|
+
_pursue_error(exc, source_type, target_type)
|
|
637
|
+
) from exc
|
|
638
|
+
raise
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _resolve_source(
|
|
642
|
+
client: LexwareClient, identifier: str
|
|
643
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
644
|
+
"""Determine a source document's (normalized type, id, full body).
|
|
645
|
+
|
|
646
|
+
A voucher number is looked up in the voucherlist (which reports the type).
|
|
647
|
+
A UUID is probed against each sales-voucher endpoint until one answers.
|
|
648
|
+
"""
|
|
649
|
+
ident = identifier.strip()
|
|
650
|
+
if _looks_like_uuid(ident):
|
|
651
|
+
for source_type, meta in _DOC_META.items():
|
|
652
|
+
try:
|
|
653
|
+
doc = client.get(meta["path"].format(id=ident))
|
|
654
|
+
except LexwareAPIError as exc:
|
|
655
|
+
if exc.status_code == 404:
|
|
656
|
+
continue
|
|
657
|
+
raise
|
|
658
|
+
return source_type, ident, doc
|
|
659
|
+
raise LexwareError(
|
|
660
|
+
f"Kein Beleg mit der ID '{identifier}' gefunden."
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Voucher number: the voucherlist row carries the type and the real id.
|
|
664
|
+
result = client.get(
|
|
665
|
+
"/v1/voucherlist",
|
|
666
|
+
params={
|
|
667
|
+
"voucherNumber": ident,
|
|
668
|
+
"voucherType": ALL_VOUCHER_TYPES, # already a comma-joined string
|
|
669
|
+
"voucherStatus": "any",
|
|
670
|
+
"size": 2,
|
|
671
|
+
},
|
|
672
|
+
)
|
|
673
|
+
rows = result.get("content") or []
|
|
674
|
+
if not rows:
|
|
675
|
+
raise LexwareError(f"Kein Beleg mit Nummer '{identifier}' gefunden.")
|
|
676
|
+
row = rows[0]
|
|
677
|
+
raw_type = row.get("voucherType", "")
|
|
678
|
+
source_type = _SOURCE_TYPE_NORMALIZE.get(raw_type, raw_type)
|
|
679
|
+
meta = _DOC_META.get(source_type)
|
|
680
|
+
if meta is None:
|
|
681
|
+
raise LexwareError(
|
|
682
|
+
f"Belegtyp '{raw_type}' von '{identifier}' kann nicht weitergeführt "
|
|
683
|
+
"werden."
|
|
684
|
+
)
|
|
685
|
+
doc = get_with_voucher_fallback(client, meta["path"].format(id=row["id"]))
|
|
686
|
+
return source_type, row["id"], doc
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _looks_like_uuid(value: str) -> bool:
|
|
690
|
+
parts = value.split("-")
|
|
691
|
+
return len(parts) == 5 and all(
|
|
692
|
+
c in "0123456789abcdefABCDEF" for c in value.replace("-", "")
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _pursue_error(exc: LexwareAPIError, source_type: str, target_type: str) -> str:
|
|
697
|
+
"""Turn a 400/406 from a pursue POST into a clear German message.
|
|
698
|
+
|
|
699
|
+
Lexware uses 406 for three cases: body validation (with a `details` list),
|
|
700
|
+
a not-yet-finalized preceding document, and a plain invalid transition.
|
|
701
|
+
Surface the most specific explanation available.
|
|
702
|
+
"""
|
|
703
|
+
to_label = _DOC_META[target_type]["label"]
|
|
704
|
+
from_label = _DOC_META.get(source_type, {}).get("label", source_type)
|
|
705
|
+
body = exc.body if isinstance(exc.body, dict) else {}
|
|
706
|
+
|
|
707
|
+
details = body.get("details")
|
|
708
|
+
if details:
|
|
709
|
+
parts = [
|
|
710
|
+
f"{d.get('field', '?')}: {d.get('message', '')}".strip(": ")
|
|
711
|
+
for d in details
|
|
712
|
+
if isinstance(d, dict)
|
|
713
|
+
]
|
|
714
|
+
joined = "; ".join(p for p in parts if p)
|
|
715
|
+
if joined:
|
|
716
|
+
return f"Weiterführen zu '{to_label}' nicht möglich: {joined}."
|
|
717
|
+
|
|
718
|
+
message = (body.get("message") or "").strip()
|
|
719
|
+
if "finali" in message.lower():
|
|
720
|
+
return (
|
|
721
|
+
f"Der Ausgangsbeleg ({from_label}) ist noch ein Entwurf. Er muss in "
|
|
722
|
+
f"Lexware erst festgeschrieben werden, bevor er zu '{to_label}' "
|
|
723
|
+
"weitergeführt werden kann."
|
|
724
|
+
)
|
|
725
|
+
if message:
|
|
726
|
+
return f"Weiterführen zu '{to_label}' nicht möglich: {message}"
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
f"Dieser {from_label} lässt sich gerade nicht zu '{to_label}' weiterführen "
|
|
730
|
+
"(z. B. weil er storniert oder bereits weitergeführt ist)."
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _pursue_body(source: dict[str, Any]) -> dict[str, Any]:
|
|
735
|
+
"""Turn a fetched document into a fresh create-body for its follow-up.
|
|
736
|
+
|
|
737
|
+
Drops server-managed/computed fields, re-dates the document to today, keeps
|
|
738
|
+
only the currency on ``totalPrice`` (amounts are recomputed server-side),
|
|
739
|
+
and strips per-line-item ids/amounts. Content (address, line items, tax /
|
|
740
|
+
shipping / payment conditions, texts, language) carries over unchanged.
|
|
741
|
+
"""
|
|
742
|
+
body = {k: v for k, v in source.items() if k not in _PURSUE_DROP_DOC}
|
|
743
|
+
body["voucherDate"] = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
744
|
+
body["lineItems"] = [
|
|
745
|
+
{k: v for k, v in item.items() if k not in _PURSUE_DROP_LINEITEM}
|
|
746
|
+
for item in (body.get("lineItems") or [])
|
|
747
|
+
]
|
|
748
|
+
currency = (source.get("totalPrice") or {}).get("currency", "EUR")
|
|
749
|
+
body["totalPrice"] = {"currency": currency}
|
|
750
|
+
# Ensure the follow-up's required blocks exist even when the source type
|
|
751
|
+
# doesn't carry them (a quotation has no shippingConditions, but an order
|
|
752
|
+
# confirmation / invoice requires one).
|
|
753
|
+
if not body.get("taxConditions"):
|
|
754
|
+
body["taxConditions"] = {"taxType": "net"}
|
|
755
|
+
if not body.get("shippingConditions"):
|
|
756
|
+
body["shippingConditions"] = {"shippingType": "none"}
|
|
757
|
+
return body
|
|
758
|
+
|
|
759
|
+
|
|
503
760
|
# -- Contacts ---------------------------------------------------------------
|
|
504
761
|
|
|
505
762
|
|
|
@@ -554,6 +811,44 @@ def create_contact(client: LexwareClient, body: dict[str, Any]) -> dict[str, Any
|
|
|
554
811
|
return client.post("/v1/contacts", body)
|
|
555
812
|
|
|
556
813
|
|
|
814
|
+
def update_contact(
|
|
815
|
+
client: LexwareClient, contact_id: str, changes: dict[str, Any]
|
|
816
|
+
) -> dict[str, Any]:
|
|
817
|
+
"""Partially update a contact: fetch, deep-merge, PUT the full object.
|
|
818
|
+
|
|
819
|
+
Lexware's PUT replaces the whole contact and requires the current
|
|
820
|
+
``version`` (optimistic locking). We therefore always fetch the live
|
|
821
|
+
contact first, merge the caller's ``changes`` onto it, and force the
|
|
822
|
+
just-fetched version — so the caller only ever supplies the fields that
|
|
823
|
+
change and can never break the version handshake.
|
|
824
|
+
"""
|
|
825
|
+
current = client.get(f"/v1/contacts/{contact_id}")
|
|
826
|
+
merged = _merge_for_update(current, changes)
|
|
827
|
+
_guard_single_contact_person(merged)
|
|
828
|
+
return _put_and_return_object(client, f"/v1/contacts/{contact_id}", merged)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _guard_single_contact_person(contact: dict[str, Any]) -> None:
|
|
832
|
+
"""Fail early on contacts the Lexware REST API refuses to update.
|
|
833
|
+
|
|
834
|
+
A company contact may hold multiple contact persons in the Lexware web
|
|
835
|
+
app, but the REST API accepts **at most one** on create/update — a PUT with
|
|
836
|
+
more returns an opaque HTTP 406 (`invalid_value` on `contactPersons.size`).
|
|
837
|
+
Since our update always PUTs the full object, such a contact can't be
|
|
838
|
+
edited via the API at all, even for an unrelated field. Turn that into a
|
|
839
|
+
clear, actionable message instead of a raw 406.
|
|
840
|
+
"""
|
|
841
|
+
persons = (contact.get("company") or {}).get("contactPersons") or []
|
|
842
|
+
if len(persons) > 1:
|
|
843
|
+
raise LexwareError(
|
|
844
|
+
"Dieser Kontakt hat mehrere Ansprechpartner. Die Lexware-API erlaubt "
|
|
845
|
+
"pro Firmen-Kontakt nur einen, deshalb lassen sich solche Kontakte "
|
|
846
|
+
"nicht über die API bearbeiten (auch nicht für andere Felder). Bitte "
|
|
847
|
+
"die Änderung direkt in der Lexware-Weboberfläche vornehmen. Für "
|
|
848
|
+
"weitere Ansprechpartner empfiehlt sich ein eigener Kontakt."
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
557
852
|
# -- Articles ---------------------------------------------------------------
|
|
558
853
|
|
|
559
854
|
|
|
@@ -600,3 +895,55 @@ def get_article(client: LexwareClient, article_id: str) -> dict[str, Any]:
|
|
|
600
895
|
|
|
601
896
|
def create_article(client: LexwareClient, body: dict[str, Any]) -> dict[str, Any]:
|
|
602
897
|
return client.post("/v1/articles", body)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def update_article(
|
|
901
|
+
client: LexwareClient, article_id: str, changes: dict[str, Any]
|
|
902
|
+
) -> dict[str, Any]:
|
|
903
|
+
"""Partially update an article — same fetch/deep-merge/PUT flow as
|
|
904
|
+
:func:`update_contact` (articles also use ``version`` locking)."""
|
|
905
|
+
current = client.get(f"/v1/articles/{article_id}")
|
|
906
|
+
merged = _merge_for_update(current, changes)
|
|
907
|
+
return _put_and_return_object(client, f"/v1/articles/{article_id}", merged)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _merge_for_update(current: dict[str, Any], changes: dict[str, Any]) -> dict[str, Any]:
|
|
911
|
+
"""Deep-merge ``changes`` onto a freshly fetched resource for a PUT.
|
|
912
|
+
|
|
913
|
+
Nested objects merge key-by-key; lists and scalars are replaced wholesale
|
|
914
|
+
(a list can't be meaningfully half-merged — pass the full list to change
|
|
915
|
+
one entry). The resource's own ``version`` always wins, so a caller who
|
|
916
|
+
echoes a stale version in ``changes`` can't trigger a false 409.
|
|
917
|
+
"""
|
|
918
|
+
merged = _deep_merge(current, changes)
|
|
919
|
+
if "version" in current:
|
|
920
|
+
merged["version"] = current["version"]
|
|
921
|
+
return merged
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _put_and_return_object(
|
|
925
|
+
client: LexwareClient, path: str, body: dict[str, Any]
|
|
926
|
+
) -> dict[str, Any]:
|
|
927
|
+
"""PUT ``body`` and return the full updated object, not Lexware's envelope.
|
|
928
|
+
|
|
929
|
+
Lexware's PUT responds with only a confirmation ({id, resourceUri, version,
|
|
930
|
+
createdDate, updatedDate}). The full object we just stored is far more
|
|
931
|
+
useful to the caller, so we return ``body`` with the new version stamped
|
|
932
|
+
in — no extra GET needed.
|
|
933
|
+
"""
|
|
934
|
+
result = client.put(path, body)
|
|
935
|
+
updated = dict(body)
|
|
936
|
+
if isinstance(result, dict):
|
|
937
|
+
for key in ("version", "updatedDate", "id", "resourceUri"):
|
|
938
|
+
if key in result:
|
|
939
|
+
updated[key] = result[key]
|
|
940
|
+
return updated
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _deep_merge(base: Any, changes: Any) -> Any:
|
|
944
|
+
if isinstance(base, dict) and isinstance(changes, dict):
|
|
945
|
+
out = dict(base)
|
|
946
|
+
for key, value in changes.items():
|
|
947
|
+
out[key] = _deep_merge(base[key], value) if key in base else value
|
|
948
|
+
return out
|
|
949
|
+
return changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Compose a non-binding feature request for the user to send to the vendor.
|
|
2
|
+
|
|
3
|
+
This is an **end-customer product**: the assistant must not offer to build or
|
|
4
|
+
extend the software itself. When a user wants functionality the tools don't
|
|
5
|
+
cover, the ``request_feature`` MCP tool returns a ready-to-send message (subject
|
|
6
|
+
+ body) and the vendor's address (oemedia) — the user copies it and sends the
|
|
7
|
+
email themselves. The server does **not** send any mail. No promise is made
|
|
8
|
+
about whether or when the feature is built.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
DEFAULT_TO = "david@oemedia.de"
|
|
14
|
+
SUBJECT = "Lexware-MCP — Funktionsanfrage (unverbindlich)"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FeatureRequestError(Exception):
|
|
18
|
+
"""Raised when a feature request cannot be composed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compose_feature_request(
|
|
22
|
+
*,
|
|
23
|
+
description: str,
|
|
24
|
+
company: str | None = None,
|
|
25
|
+
contact_email: str | None = None,
|
|
26
|
+
) -> dict[str, str]:
|
|
27
|
+
"""Build a copy-paste-ready feature request. Sends nothing.
|
|
28
|
+
|
|
29
|
+
Returns ``{to, subject, body, hinweis}``. Raises
|
|
30
|
+
:class:`FeatureRequestError` on empty input.
|
|
31
|
+
"""
|
|
32
|
+
text = (description or "").strip()
|
|
33
|
+
if not text:
|
|
34
|
+
raise FeatureRequestError("Bitte den gewünschten Funktionswunsch beschreiben.")
|
|
35
|
+
|
|
36
|
+
subject = SUBJECT + (f" — {company}" if company else "")
|
|
37
|
+
lines = [
|
|
38
|
+
"Unverbindliche Funktionsanfrage zum Lexware-MCP-Tool.",
|
|
39
|
+
"",
|
|
40
|
+
f"Kunde: {company or '(bitte ergänzen)'}",
|
|
41
|
+
f"Kontakt-Mail: {contact_email or '(bitte ergänzen)'}",
|
|
42
|
+
"",
|
|
43
|
+
"Wunsch:",
|
|
44
|
+
text,
|
|
45
|
+
"",
|
|
46
|
+
"Hinweis: unverbindlich, keine Zusage zur Umsetzung.",
|
|
47
|
+
]
|
|
48
|
+
return {
|
|
49
|
+
"to": DEFAULT_TO,
|
|
50
|
+
"subject": subject,
|
|
51
|
+
"body": "\n".join(lines),
|
|
52
|
+
"hinweis": (
|
|
53
|
+
"Dies ist ein unverbindlicher Funktionswunsch. Bitte den obigen Text "
|
|
54
|
+
f"per E-Mail an {DEFAULT_TO} senden — der Server verschickt nichts "
|
|
55
|
+
"selbst."
|
|
56
|
+
),
|
|
57
|
+
}
|