vCard-Import fürs Autohaus, Selbst-Update-Logging und UI-Feinschliff
- Service -> Autohaus -> Zahnrad hat jetzt einen "vCard importieren"-Knopf (öffnet den Dateiauswahldialog für .vcf). Liest FN/ORG, TEL, EMAIL, ADR und befüllt das Formular - in beiden Frontends, jeweils passend zum bestehenden Speicherverhalten (Panel speichert sofort, companion-app erst über den vorhandenen "Speichern"-Knopf). - update_pruefen/update_installieren loggten bisher nur bei einem Fehler - ein erfolgreicher Aufruf war im Log nicht von "noch nicht ausgeführt" zu unterscheiden. Jetzt auch eine Erfolgsmeldung. - Panel: der feste Hinweis auf den fehlenden Gitea-Token stand dauerhaft in der Integration-Update-Kachel, auch wenn ein Token längst hinterlegt war. Erscheint jetzt nur noch als Teil einer echten Fehlermeldung. Details in AGENTS.md, Abschnitte J (Nachtrag) und K. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Autohaus>({ ...autohausRoh })
|
||||
const [laeuft, setzeLaeuft] = useState(false)
|
||||
const [gespeichert, setzeGespeichert] = useState(false)
|
||||
const [vcardFehler, setzeVcardFehler] = useState<string | null>(null)
|
||||
const vcardDatei = useRef<HTMLInputElement | null>(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) => (
|
||||
<Feld label={beschriftung} last={letztes}>
|
||||
<input
|
||||
@@ -238,12 +268,25 @@ export function Werkstatt() {
|
||||
{feld("adresse", "Adresse")}
|
||||
{feld("tel", "Telefon")}
|
||||
{feld("mail", "E-Mail", true)}
|
||||
{vcardFehler && <p className="dm-fehler">{vcardFehler}</p>}
|
||||
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="dm-knopfreihe" style={{ marginTop: 14 }}>
|
||||
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Speichern"}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => vcardDatei.current?.click()}>vCard importieren</ActionButton>
|
||||
</div>
|
||||
<input
|
||||
ref={vcardDatei}
|
||||
type="file"
|
||||
accept=".vcf,text/vcard"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const datei = e.target.files?.[0]
|
||||
if (datei) vcardImportieren(datei)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user