diff --git a/AGENTS.md b/AGENTS.md index ba25df8..7b2a299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2296,6 +2296,47 @@ store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to update* an already-running instance. +**Two fixes found during the owner's own first live test (2026-08-24, same day).** First attempt +against the test container ("nothing changed") turned out to be a real user error caught by log +inspection, not a bug: the check was clicked before the token had actually been saved in the +Options Flow — `dienste.py`'s log showed exactly why (`Kein Gitea-Token hinterlegt`), confirming the +error path works. But this surfaced a genuine observability gap: `update_pruefen`/ +`update_installieren` only ever logged on *failure* — a successful check was silent, so "nothing in +the log" was ambiguous between "not clicked yet" and "worked fine." Fixed by adding an `_LOGGER.info` +line on the success path of both services. Separately, the owner asked for the panel's idle-state +text ("Ein Gitea-Zugriffstoken muss zuvor... eingetragen sein") to only appear as part of an actual +error, not as a permanent reminder cluttering the tile before the first check — removed from the +idle branch; the same information already surfaces via `integration_update.fehler` when it's +actually true. companion-app never had this line in the first place, so no parity fix needed there. + +### K) vCard import for the workshop contact (built 2026-08-24) + +Service → Autohaus (gear icon) now has a "vCard importieren" button next to the existing form +fields, opening a file picker for a `.vcf` contact exported from a phone/desktop address book. Reads +`FN`/`ORG` (name, `FN` preferred), `TEL`, `EMAIL`, and `ADR` (the seven-part vCard address — +PO-Box/extended/street/locality/region/postal-code/country — joined into one readable line, since +the form's address field is a single string). Deliberately not a full vCard parser: only the four +fields the Autohaus form already has. Unfolds RFC 6350 §3.2 folded lines (CRLF + one WSP removed +entirely, not replaced with a space) and un-escapes `\,`/`\;`/`\n`/`\\` before use. Multiple +`TEL`/`EMAIL`/`ADR` lines: first one wins. + +- Panel: `vcardParsen()` in `audi-dashboard-app.js`, wired into `vWerkstatt()`'s new hidden + `data-vcard-import` file input via the existing central `change` listener — same + parse-then-`hinweis()`-on-error pattern as the existing JSON profile/backup import handlers. + Updates `CAR.service.autohaus` fields directly and calls the existing `profilSpeichern()` (saves + immediately, matching this field's existing per-keystroke-save behavior). + Same isolated-function shape as `entpacken_pruefen_tauschen()` in section J's self-updater: parse + logic takes no dependency on `HASS`/global state, so it's directly testable. +- companion-app: new `src/screens/vcard.ts` (`vcardParsen()`, 7 unit tests in `vcard.test.ts` + covering folding, escaping, ORG fallback, first-wins on repeats, and the two "nothing readable" + error cases), wired into `Werkstatt()` in `Service.tsx`. Unlike the panel, this only populates the + local `entwurf` draft state — the existing explicit "Speichern" button still does the actual save, + matching this screen's existing edit-then-confirm pattern rather than the panel's live-save one. + Empty fields from the vCard never overwrite non-empty draft values. + +Verified: `tsc --noEmit` clean, all 134 companion-app tests pass (127 + 7 new), panel JS re-checked +with `node --check`. Manifest bumped to `2026.8.24.5`, `npm run ota` rerun (both frontends changed). + --- ## Working conventions (observed — keep them) diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index f618171..c37badc 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -8,7 +8,7 @@ * eingestellten Intervallen abweichen. */ -import { useState } from "react" +import { useRef, useState } from "react" import { ActionButton, Feld, Tile } from "@audi-dash/ui" @@ -25,6 +25,7 @@ import type { SeitenName } from "../navigation" import { SymbolInspektion, SymbolOelwechsel } from "../symbole" import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { kalenderDateiLaden } from "./kalender" +import { vcardParsen } from "./vcard" export interface ServicebuchEintrag { datum?: string @@ -206,6 +207,8 @@ export function Werkstatt() { const [entwurf, setzeEntwurf] = useState({ ...autohausRoh }) const [laeuft, setzeLaeuft] = useState(false) const [gespeichert, setzeGespeichert] = useState(false) + const [vcardFehler, setzeVcardFehler] = useState(null) + const vcardDatei = useRef(null) if (!fahrzeug) return null @@ -221,6 +224,33 @@ export function Werkstatt() { } } + // Befüllt nur den Entwurf, speichert nicht selbst - der Nutzer prüft die + // übernommenen Felder wie bei jeder anderen Änderung erst im Formular, + // bevor "Speichern" etwas an Home Assistant schickt. Leere Felder aus der + // vCard überschreiben vorhandene Werte im Entwurf nicht. + const vcardImportieren = (datei: File) => { + setzeVcardFehler(null) + setzeGespeichert(false) + const leser = new FileReader() + leser.onload = () => { + try { + const felder = vcardParsen(String(leser.result)) + setzeEntwurf((alt) => ({ + ...alt, + ...(felder.name ? { name: felder.name } : {}), + ...(felder.adresse ? { adresse: felder.adresse } : {}), + ...(felder.tel ? { tel: felder.tel } : {}), + ...(felder.mail ? { mail: felder.mail } : {}), + })) + } catch (fehler) { + setzeVcardFehler( + fehler instanceof Error ? fehler.message : "Die vCard ließ sich nicht lesen.", + ) + } + } + leser.readAsText(datei) + } + const feld = (schluessel: keyof Autohaus, beschriftung: string, letztes = false) => ( {vcardFehler}

} {gespeichert &&

Gespeichert.

} -
+
void speichern()} disabled={laeuft}> {laeuft ? "Speichere …" : "Speichern"} + vcardDatei.current?.click()}>vCard importieren
+ { + const datei = e.target.files?.[0] + if (datei) vcardImportieren(datei) + e.target.value = "" + }} + /> ) } diff --git a/companion-app/src/screens/vcard.test.ts b/companion-app/src/screens/vcard.test.ts new file mode 100644 index 0000000..e3e4917 --- /dev/null +++ b/companion-app/src/screens/vcard.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest" + +import { vcardParsen } from "./vcard" + +describe("vcardParsen", () => { + it("liest FN, TEL, EMAIL und eine zusammengesetzte ADR", () => { + const vcard = [ + "BEGIN:VCARD", + "VERSION:3.0", + "FN:Autohaus Mustermann", + "TEL;TYPE=WORK,VOICE:+49 123 4567890", + "EMAIL;TYPE=INTERNET:info@example.com", + "ADR;TYPE=WORK:;;Musterstraße 1;Musterstadt;;12345;Deutschland", + "END:VCARD", + ].join("\r\n") + + expect(vcardParsen(vcard)).toEqual({ + name: "Autohaus Mustermann", + adresse: "Musterstraße 1, 12345 Musterstadt, Deutschland", + tel: "+49 123 4567890", + mail: "info@example.com", + }) + }) + + it("fällt ohne FN auf ORG zurück", () => { + const vcard = ["BEGIN:VCARD", "VERSION:3.0", "ORG:Autohaus GmbH", "END:VCARD"].join("\n") + expect(vcardParsen(vcard).name).toBe("Autohaus GmbH") + }) + + it("entfaltet gefaltete Zeilen vor dem Parsen", () => { + // CRLF + genau ein WSP-Zeichen werden beim Entfalten vollständig entfernt + // (RFC 6350 §3.2) - hier mitten im Wort "Mustermann" gefaltet, damit ein + // falsches Verhalten (WSP durch ein Leerzeichen statt durch nichts + // ersetzt) den Test sichtbar brechen würde. + const vcard = ["BEGIN:VCARD", "VERSION:3.0", "FN:Autohaus Muster\n mann", "END:VCARD"].join( + "\n", + ) + expect(vcardParsen(vcard).name).toBe("Autohaus Mustermann") + }) + + it("entfernt vCard-Escapezeichen aus Feldwerten", () => { + const vcard = ["BEGIN:VCARD", "VERSION:3.0", "FN:Mueller\\, Schmidt und Partner"].join("\n") + expect(vcardParsen(vcard).name).toBe("Mueller, Schmidt und Partner") + }) + + it("nimmt jeweils nur die erste Zeile bei mehrfachen TEL/EMAIL/ADR", () => { + const vcard = [ + "BEGIN:VCARD", + "VERSION:3.0", + "TEL;TYPE=WORK:+49 111", + "TEL;TYPE=CELL:+49 222", + "EMAIL:erste@example.com", + "EMAIL:zweite@example.com", + "END:VCARD", + ].join("\n") + const f = vcardParsen(vcard) + expect(f.tel).toBe("+49 111") + expect(f.mail).toBe("erste@example.com") + }) + + it("wirft einen Fehler ohne jedes lesbare Feld", () => { + const vcard = ["BEGIN:VCARD", "VERSION:3.0", "END:VCARD"].join("\n") + expect(() => vcardParsen(vcard)).toThrow() + }) + + it("wirft einen Fehler bei völlig fremdem Dateiinhalt", () => { + expect(() => vcardParsen("Das ist keine vCard.")).toThrow() + }) +}) diff --git a/companion-app/src/screens/vcard.ts b/companion-app/src/screens/vcard.ts new file mode 100644 index 0000000..2bee91a --- /dev/null +++ b/companion-app/src/screens/vcard.ts @@ -0,0 +1,66 @@ +/** + * Liest Name, Adresse, Telefon und E-Mail aus einer vCard (.vcf) - deckt + * vCard 3.0/4.0 so weit ab, wie ein typischer Werkstatt-Kontakt aus dem + * Adressbuch (iOS/Android/Outlook) sie befüllt. Kein vollständiger Parser, + * nur die vier Felder, die das Autohaus-Formular (Werkstatt() in + * Service.tsx) kennt. Deckungsgleich mit vcardParsen() im Panel + * (audi-dashboard-app.js). + */ + +export interface VcardFelder { + name: string | null + adresse: string | null + tel: string | null + mail: string | null +} + +/** Entfernt vCard-Escapes (\n, \,, \;, \\) aus einem Feldwert. */ +function entwerten(s: string): string { + return s.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\") +} + +export function vcardParsen(text: string): VcardFelder { + // Gefaltete Zeilen (Fortsetzung mit führendem Leerzeichen/Tab, RFC 6350 + // §3.2) vor dem zeilenweisen Parsen entfalten. + const entfaltet = text.replace(/\r\n/g, "\n").replace(/\n[ \t]/g, "") + const zeilen = entfaltet + .split("\n") + .map((z) => z.trim()) + .filter(Boolean) + + let name: string | null = null + let org: string | null = null + let tel: string | null = null + let mail: string | null = null + let adresse: string | null = null + + for (const zeile of zeilen) { + const doppelpunkt = zeile.indexOf(":") + if (doppelpunkt < 0) continue + const eigenschaft = zeile.slice(0, doppelpunkt).split(";")[0]!.toUpperCase() + const wert = zeile.slice(doppelpunkt + 1) + + if (eigenschaft === "FN" && !name) { + name = entwerten(wert) + } else if (eigenschaft === "ORG" && !org) { + org = entwerten(wert.split(";")[0]!) + } else if (eigenschaft === "TEL" && !tel) { + tel = entwerten(wert) + } else if (eigenschaft === "EMAIL" && !mail) { + mail = entwerten(wert) + } else if (eigenschaft === "ADR" && !adresse) { + // PO-Box(0), Zusatz(1), Straße(2), Ort(3), Region(4), PLZ(5), Land(6) - + // zu einer lesbaren Ein-Zeilen-Adresse zusammengefasst, weil das + // Formularfeld nur eine Zeile kennt. + const teile = wert.split(";").map(entwerten) + const strasse = [teile[1], teile[2]].filter(Boolean).join(" ") + const ort = [teile[5], teile[3]].filter(Boolean).join(" ") + adresse = [strasse, ort, teile[6]].filter(Boolean).join(", ") || null + } + } + + if (!name && !org && !tel && !mail && !adresse) { + throw new Error("Keine lesbaren Kontaktfelder gefunden - ist das eine .vcf-Datei?") + } + return { name: name ?? org, adresse, tel, mail } +} diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 3e8fa72..d8a140e 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -228,6 +228,10 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat() try: ergebnis = await aktualisierung.version_pruefen(hass, token, k.version) + _LOGGER.info( + "Update-Prüfung: Gitea hat Version %s (installiert: %s, verfügbar: %s)", + ergebnis["version"], k.version, ergebnis["verfuegbar"], + ) k.letzte_update_pruefung = { "verfuegbar": ergebnis["verfuegbar"], "version": ergebnis["version"], @@ -255,6 +259,10 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat() try: ergebnis = await aktualisierung.update_installieren(hass, token) + _LOGGER.info( + "Update installiert: Version %s - Home Assistant muss neu gestartet werden.", + ergebnis["version"], + ) k.letzte_update_pruefung = { "verfuegbar": False, "version": ergebnis["version"], diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 33c011f..d38d012 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"} \ No newline at end of file +{"version":"2026.8.24.5","sha256":"378a1cbc7f5daa3d8c5d74734a4c09f30557b243a8ce4d720240bf911fbeff76","bytes":232096,"gebaut":"2026-08-24T10:29:33Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index aa45278..9bc849b 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 689c9db..5e3ae18 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1969,6 +1969,44 @@ function termine() { ]; } +/* Liest Name, Adresse, Telefon und E-Mail aus einer vCard (.vcf) - deckt + vCard 3.0/4.0 so weit ab, wie ein typischer Werkstatt-Kontakt aus dem + Adressbuch (iOS/Android/Outlook) sie befüllt. Kein vollständiger Parser, + nur die vier Felder, die das Autohaus-Formular kennt. + + Gefaltete Zeilen (Fortsetzung mit führendem Leerzeichen/Tab, RFC 6350 + §3.2) werden vor dem zeilenweisen Parsen entfaltet; ADR setzt sich aus + PO-Box, Zusatz, Straße, Ort, Region, PLZ, Land zusammen (in dieser + Reihenfolge, mit Semikolon getrennt) - hier zu einer lesbaren + Ein-Zeilen-Adresse zusammengefasst, weil das Formularfeld nur eine + Zeile kennt. Mehrfache TEL/EMAIL/ADR-Zeilen: die erste gewinnt. */ +function vcardParsen(text) { + const entfaltet = String(text).replace(/\r\n/g, "\n").replace(/\n[ \t]/g, ""); + const zeilen = entfaltet.split("\n").map((z) => z.trim()).filter(Boolean); + const entwerten = (s) => s.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\"); + let name = null, org = null, tel = null, mail = null, adresse = null; + for (const zeile of zeilen) { + const doppelpunkt = zeile.indexOf(":"); + if (doppelpunkt < 0) continue; + const eigenschaft = zeile.slice(0, doppelpunkt).split(";")[0].toUpperCase(); + const wert = zeile.slice(doppelpunkt + 1); + if (eigenschaft === "FN" && !name) name = entwerten(wert); + else if (eigenschaft === "ORG" && !org) org = entwerten(wert.split(";")[0]); + else if (eigenschaft === "TEL" && !tel) tel = entwerten(wert); + else if (eigenschaft === "EMAIL" && !mail) mail = entwerten(wert); + else if (eigenschaft === "ADR" && !adresse) { + const teile = wert.split(";").map(entwerten); + const strasse = [teile[1], teile[2]].filter(Boolean).join(" "); + const ort = [teile[5], teile[3]].filter(Boolean).join(" "); + adresse = [strasse, ort, teile[6]].filter(Boolean).join(", ") || null; + } + } + if (!name && !org && !tel && !mail && !adresse) { + throw new Error("Keine lesbaren Kontaktfelder gefunden - ist das eine .vcf-Datei?"); + } + return { name: name || org || null, adresse, tel, mail }; +} + function vWerkstatt() { const a = CAR.service.autohaus; return ` @@ -1978,6 +2016,8 @@ function vWerkstatt() {
+
`; } @@ -2405,9 +2445,7 @@ function vEinst() { `; } return ` - ${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."} - Ein Gitea-Zugriffstoken muss zuvor unter Einstellungen -> Geräte & Dienste -> Audi - Dashboard -> Konfigurieren eingetragen sein. + ${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."} `; })()} ${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler @@ -4612,6 +4650,23 @@ function ereignisseVerdrahten() { leser.readAsText(datei); return; } + if (e.target.dataset.vcardImport !== undefined) { + const datei = e.target.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + try { + const felder = vcardParsen(leser.result); + if (felder.name) CAR.service.autohaus.name = felder.name; + if (felder.adresse) CAR.service.autohaus.adresse = felder.adresse; + if (felder.tel) CAR.service.autohaus.tel = felder.tel; + if (felder.mail) CAR.service.autohaus.mail = felder.mail; + profilSpeichern(); render(); + } catch (err) { hinweis("vCard konnte nicht gelesen werden", err.message); } + }; + leser.readAsText(datei); + e.target.value = ""; + return; + } if (e.target.dataset.bildupload !== undefined) { const datei = e.target.files[0]; if (!datei) return; const dateiname = e.target.dataset.bildupload; diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 8a90256..54a6a3c 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.8.24.4", + "version": "2026.8.24.5", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"],