Kalender ueber EventKit, Orte nach dem Import, Fotos verkleinert, Signalsperre (2026.9.3.1-.3)

- Kalendertermine gehen nativ ueber EventKit statt ueber das Teilen-Blatt:
  Apples Kalender meldet sich beim System gar nicht als Teilen-Ziel an, ein
  anderes Dateiformat haette also nie geholfen (Abschnitt BS).
- Der Rueckblick stoesst jetzt selbst das Screening an, wenn er Fahrten
  angelegt hat - sonst blieb eine importierte Fahrt ohne Ort liegen, solange
  das Fahrzeug steht (Abschnitt BT).
- Fahrzeugfotos werden vor dem Upload im Browser auf 2000 px/WebP gebracht.
  Gemessen: 3.099.022 -> 325.994 Bytes. Damit ist die Nachrichtengrenze der
  WebSocket-Verbindung kein Thema mehr ("connection lost" auf der realen
  Instanz). Formate, die der Browser weder umwandeln noch anzeigen kann,
  werden mit klarer Meldung abgelehnt statt roh gespeichert.
- Zwei Wechsel des Fahrtsignals in derselben Sekunde kosteten eine ganze
  Fahrt: das "on" ueberholte den noch laufenden Beende-Vorgang und fiel durch
  beide Zweige. Neue asyncio-Sperre plus Regressionstest, der ohne sie
  nachweislich rot ist (Abschnitt BU).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 01:59:32 +02:00
parent 8c95e50554
commit 6016053a92
18 changed files with 1072 additions and 78 deletions
+135
View File
@@ -0,0 +1,135 @@
//
// KalenderTermin.swift
// DataMetric360
//
// Legt einen Termin über EventKit an der Weg, den das Teilen-Blatt nicht
// anbietet.
//
// WARUM ES DAS BRAUCHT
// --------------------
// Bis zum 03.09.2026 ging der Werkstatt- und der Radwechseltermin als
// .ics-Datei ins iOS-Teilen-Blatt. Dort erscheint nie ein Kalender, und das
// liegt nicht an der Datei: Apples Kalender-App meldet sich beim System
// überhaupt nicht als Teilen-Ziel an, für kein Format. Das Blatt kann sie
// deshalb gar nicht zeigen, egal wie die Datei aussieht.
//
// Dass ein per Mail geschickter Anhang funktioniert, führt in die Irre: dort
// öffnet nicht das Teilen-Blatt, sondern die Dokumentvorschau des Systems
// (Quick Look). Die erkennt text/calendar und blendet Alle hinzufügen" ein.
// Ein anderer Mechanismus, den eine App nicht aus dem Teilen-Blatt heraus
// erreicht. Gemeldet vom Eigentümer am 02.09.2026.
//
// EventKit umgeht beides: der Termin geht direkt an den Kalender.
//
// WARUM DER SYSTEM-EDITOR UND NICHT `EKEventStore.save`
// -----------------------------------------------------
// `EKEventEditViewController` zeigt den fertig ausgefüllten Termin und lässt
// ihn den Menschen bestätigen. Das passt zur Vorgabe des Eigentümers, dass
// der Werkstatttermin keine eigene Verwaltung bekommt (siehe Abschnitt BQ in
// AGENTS.md): die App liefert was, wann, welche Werkstatt" und ist danach
// raus gespeichert wird im Kalender, nicht bei uns.
//
// Der zweite Grund ist die Berechtigung. Seit iOS 17 braucht der System-
// Editor keine (WWDC23, Discover Calendar and EventKit"): er läuft außerhalb
// unseres Zugriffs, wir übergeben nur den Entwurf. Ein direktes `save()`
// bräuchte dagegen eine Freigabe samt Rückfrage. Deshalb wird hier auch
// `event.calendar` NICHT gesetzt das würde `defaultCalendarForNewEvents`
// lesen und damit doch eine Freigabe verlangen; der Editor sucht den
// Standardkalender selbst.
//
// DIESE DATEI IST VERSIONIERT, ios/ NICHT.
// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin
// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau
// wieder hinein und hängt sie an die Sources-Phase des App-Ziels.
//
import Capacitor
import EventKit
import EventKitUI
import Foundation
import UIKit
@objc(KalenderTerminPlugin)
public class KalenderTerminPlugin: CAPPlugin, CAPBridgedPlugin, EKEventEditViewDelegate {
public let identifier = "KalenderTerminPlugin"
public let jsName = "KalenderTermin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "terminAnlegen", returnType: CAPPluginReturnPromise)
]
/// Muss den Editor überleben eine lokale Instanz wäre beim Erscheinen
/// des Blatts schon wieder abgeräumt.
private let speicher = EKEventStore()
/// Der Aufruf wartet, bis der Mensch im Editor entschieden hat.
private var offenerAufruf: CAPPluginCall?
@objc func terminAnlegen(_ call: CAPPluginCall) {
guard let titel = call.getString("titel"), !titel.isEmpty else {
return call.reject("Ohne Titel kein Termin.")
}
guard let isoTag = call.getString("isoTag"), let tag = tagAus(isoTag) else {
return call.reject("Kein gültiges Datum (erwartet JJJJ-MM-TT).")
}
let termin = EKEvent(eventStore: speicher)
termin.title = titel
termin.isAllDay = true
// Ganztägig heißt für EventKit: Beginn und Ende am selben Tag. Deckt
// sich mit dem DTSTART;VALUE=DATE der bisherigen .ics.
termin.startDate = tag
termin.endDate = tag
if let ort = call.getString("ort"), !ort.isEmpty { termin.location = ort }
if let text = call.getString("beschreibung"), !text.isEmpty { termin.notes = text }
// Zwei Tage vorher erinnern - dieselbe Vorwarnzeit wie im TRIGGER:-P2D
// der .ics, die dieser Weg ablöst.
termin.addAlarm(EKAlarm(relativeOffset: -2 * 24 * 60 * 60))
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let eltern = self.bridge?.viewController else {
return call.reject("Kein Fenster zum Anzeigen des Kalender-Editors.")
}
// Ein noch offener Aufruf kann nur von einem Editor stammen, der
// ohne Rueckmeldung verschwunden ist. Ihn stehen zu lassen hiesse,
// ein Versprechen nie einzuloesen.
self.offenerAufruf?.resolve(["status": "abgebrochen"])
self.offenerAufruf = call
let editor = EKEventEditViewController()
editor.event = termin
editor.eventStore = self.speicher
editor.editViewDelegate = self
// Wegwischen wuerde den Delegaten nicht rufen und den Aufruf oben
// haengen lassen; so bleiben nur "Hinzufuegen" und "Abbrechen".
editor.isModalInPresentation = true
eltern.present(editor, animated: true)
}
}
public func eventEditViewController(
_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction
) {
controller.dismiss(animated: true)
let aufruf = offenerAufruf
offenerAufruf = nil
// .deleted gibt es hier nicht (der Termin ist neu), zaehlt aber wie ein
// Abbruch: gespeichert wurde nichts.
aufruf?.resolve(["status": action == .saved ? "gespeichert" : "abgebrochen"])
}
/// 2027-03-14" -> Mitternacht dieses Tages in der Zeitzone des Geräts.
private func tagAus(_ isoTag: String) -> Date? {
let teile = isoTag.split(separator: "-").compactMap { Int($0) }
guard teile.count == 3 else { return nil }
var komponenten = DateComponents()
komponenten.year = teile[0]
komponenten.month = teile[1]
komponenten.day = teile[2]
var kalender = Calendar(identifier: .gregorian)
kalender.timeZone = TimeZone.current
return kalender.date(from: komponenten)
}
}
+20
View File
@@ -106,6 +106,26 @@ fi
exit 1
}
# Kalender-Berechtigung, aus demselben Grund an derselben Stelle.
#
# Warum ZWEI Schluessel: seit iOS 17 gibt es einen eigenen Nur-Schreiben-Zugriff
# (NSCalendarsWriteOnlyAccessUsageDescription), aeltere Fassungen kennen nur den
# alten Sammelschluessel. Der System-Editor (native/KalenderTermin.swift) fragt
# auf iOS 17+ von sich aus gar nicht - die Schluessel sind die Absicherung fuer
# den Fall, dass EventKit doch einmal fragt. Fehlt der passende, beendet iOS die
# App wortlos, statt eine Rueckfrage zu zeigen.
echo "==> Kalender-Berechtigung eintragen"
KALENDERGRUND="Traegt Werkstatt- und Wechseltermine in deinen Kalender ein."
for SCHLUESSEL in NSCalendarsWriteOnlyAccessUsageDescription NSCalendarsUsageDescription; do
if ! /usr/libexec/PlistBuddy -c "Set :$SCHLUESSEL $KALENDERGRUND" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :$SCHLUESSEL string $KALENDERGRUND" "$PLIST"
fi
/usr/libexec/PlistBuddy -c "Print :$SCHLUESSEL" "$PLIST" >/dev/null || {
echo "FEHLER: $SCHLUESSEL liess sich nicht setzen." >&2
exit 1
}
done
# Share-Erweiterung ins Xcode-Projekt haengen.
#
# Aus demselben Grund wie die Team-Kennung und der Standort-Schluessel oben:
+21 -13
View File
@@ -53,6 +53,11 @@ const ZIEL_NAME = "DataMetric360Share"
const BUNDLE_ID = "app.datametric360.teilen"
const GRUPPE = "group.app.datametric360"
/* Eigene Capacitor-Plugins der App - je eine Swift-Datei, die neben der
Erweiterung ins App-Ziel gehört. Sie liegen aus demselben Grund unter
native/ wie alles andere hier: ios/ ist gitignored und wird neu erzeugt. */
const PLUGIN_DATEIEN = ["BelegZwischenablage.swift", "KalenderTermin.swift"]
/** pbxproj-Wert ohne die optionalen Anfuehrungszeichen. */
const entquotet = (wert) => String(wert ?? "").replace(/^"|"$/g, "")
const SCHEMA = "datametric360"
@@ -87,10 +92,9 @@ for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.en
copyFileSync(join(quelleErw, datei), join(zielErw, datei))
}
copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements"))
copyFileSync(
join(APP, "native", "BelegZwischenablage.swift"),
join(iosApp, "App", "BelegZwischenablage.swift"),
)
for (const datei of PLUGIN_DATEIEN) {
copyFileSync(join(APP, "native", datei), join(iosApp, "App", datei))
}
melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`)
/* ------------------------------------------------------------- URL-Schema */
@@ -129,24 +133,28 @@ if (!appPlist.includes("CFBundleURLTypes")) {
const projekt = xcode.project(pbxPfad)
projekt.parseSync()
/* ------------------------------------------- Zwischenablage-Plugin (App-Ziel)
/* ------------------------------------------------ Eigene Plugins (App-Ziel)
Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf
vorhanden, das Plugin muss trotzdem bei jedem Bau geprüft werden - ios/
vorhanden, die Plugins müssen trotzdem bei jedem Bau geprüft werden - ios/
wird von `npx cap add ios` jederzeit neu erzeugt.
`addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des
Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe
landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung
ohne Programm entstanden, siehe unten.) */
const PLUGIN_DATEI = "BelegZwischenablage.swift"
const schonDrin = JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(PLUGIN_DATEI)
if (schonDrin) {
melden("Zwischenablage-Plugin war bereits im App-Ziel")
} else {
projekt.addSourceFile(`App/${PLUGIN_DATEI}`)
let pluginsGeaendert = false
for (const datei of PLUGIN_DATEIEN) {
if (JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(datei)) {
melden(`${datei} war bereits im App-Ziel`)
continue
}
projekt.addSourceFile(`App/${datei}`)
melden(`${datei} ins App-Ziel gehängt`)
pluginsGeaendert = true
}
if (pluginsGeaendert) {
writeFileSync(pbxPfad, projekt.writeSync())
melden("Zwischenablage-Plugin ins App-Ziel gehängt")
// Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf
// einem Stand, der die eigene Änderung nicht kennt.
projekt.parseSync()
+11 -6
View File
@@ -18,7 +18,7 @@ import { datum, de, deOderStrich } from "../format"
import { SymbolEdit, SymbolPaket, SymbolZahnradVoll } from "../symbole"
import { BildMitMenue, WischLoeschen, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
type SatzSchluessel = "sommer" | "winter"
@@ -30,6 +30,7 @@ export function Reifen() {
const [kmOffen, setzeKmOffen] = useState<SatzSchluessel | null>(null)
const [kmEntwurf, setzeKmEntwurf] = useState("")
const [wechselDatum, setzeWechselDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -51,6 +52,7 @@ export function Reifen() {
}
const kalenderUebernehmen = async () => {
setzeKalenderFehler(null)
await profilSpeichern({
fahrzeug: {
...fahrzeug,
@@ -58,11 +60,13 @@ export function Reifen() {
},
})
const ziel = aktiv === "Sommer" ? "Winter" : "Sommer"
await kalenderDateiLaden(
"Reifenwechsel",
wechselDatumAnzeige,
`Wechsel auf ${ziel}reifen`,
)
try {
await terminUebernehmen("Reifenwechsel", wechselDatumAnzeige, `Wechsel auf ${ziel}reifen`)
} catch (fehler) {
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
)
}
}
const kmSpeichern = async (satzSchluessel: SatzSchluessel) => {
@@ -309,6 +313,7 @@ export function Reifen() {
In den Kalender übernehmen
</ActionButton>
</div>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
{satzTile("sommer")}
+11 -4
View File
@@ -32,7 +32,7 @@ import {
bestaetigen,
} from "./bausteine"
import { Datumsfeld } from "./Datumsfeld"
import { kalenderDateiLaden } from "./kalender"
import { terminUebernehmen } from "./kalender"
import { vcardParsen } from "./vcard"
export interface ServicebuchEintrag {
@@ -270,6 +270,7 @@ function TerminVereinbaren() {
Sobald jemand ein Datum wählt, bleibt es stehen, auch wenn die Art
wechselt - sonst würde die eigene Eingabe stillschweigend überschrieben. */
const [eigenesDatum, setzeEigenesDatum] = useState<string | null>(null)
const [kalenderFehler, setzeKalenderFehler] = useState<string | null>(null)
if (!fahrzeug) return null
@@ -319,17 +320,23 @@ function TerminVereinbaren() {
Panel (`ics()` dort). Fehlt das Autohaus, bleiben beide Felder leer
und der Termin trägt nur "was" und "wann". */}
<ActionButton
onClick={() =>
void kalenderDateiLaden(
onClick={() => {
setzeKalenderFehler(null)
void terminUebernehmen(
`${einstellungen?.fahrzeugtitel ?? "Fahrzeug"}: ${art}`,
datumWert,
autohaus.adresse ?? "",
autohaus.name ?? "",
).catch((fehler) =>
setzeKalenderFehler(
fehler instanceof Error ? fehler.message : "Der Termin ließ sich nicht übergeben.",
),
)
}
}}
>
In den Kalender übernehmen
</ActionButton>
{kalenderFehler && <p className="dm-fehler">{kalenderFehler}</p>}
</Tile>
)
}
+15 -8
View File
@@ -11,7 +11,7 @@ import type { SicherheitsPunkt } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { SymbolZahnradVoll } from "../symbole"
import { Bild } from "./Bild"
import { bildVersionErneuern } from "./bilder"
import { bildAlsBase64, bildVerkleinern, bildVersionErneuern } from "./bilder"
/** Kraftstoffsorten zur Auswahl - dieselbe feste Liste wie `KRAFTSTOFFSORTEN`
im Panel, geteilt zwischen Tanken.tsx (Neuanlegen) und TankDetail.tsx
@@ -96,15 +96,16 @@ export function BildMitMenue({
}) {
const { api, jetztAktualisieren } = useDaten()
const [offen, setzeOffen] = useState(false)
/* Ein Fehlschlag beim Hochladen war bisher unsichtbar: der Aufruf lief als
`void hochladen(f)` ins Leere. Jetzt steht er unter dem Bild. */
const [fehler, setzeFehler] = useState<string | null>(null)
const datei_ = useRef<HTMLInputElement | null>(null)
const hochladen = async (gewaehlt: File) => {
const base64 = await new Promise<string>((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(gewaehlt)
})
setzeFehler(null)
/* Verkleinern vor dem Senden - Begründung in bilder.ts. Ein Foto direkt
aus der Kamera sprengt sonst die Nachrichtengrenze der Verbindung. */
const base64 = await bildAlsBase64(await bildVerkleinern(gewaehlt))
await api.bildHochladen(datei, base64)
/* Ohne diese beiden Zeilen bliebe das alte Foto stehen: die Adresse ist
unveraendert, und Home Assistant liefert /local/ mit 31 Tagen
@@ -133,10 +134,16 @@ export function BildMitMenue({
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void hochladen(f)
if (f)
void hochladen(f).catch((problem) =>
setzeFehler(
problem instanceof Error ? problem.message : "Hochladen fehlgeschlagen.",
),
)
e.target.value = ""
}}
/>
{fehler && <p className="dm-fehler">{fehler}</p>}
{/* Auswahlblatt statt schwebendem Menü: zwei Wege, die der Nutzer
selbst angestoßen hat, plus ein abgesetztes Abbrechen - genau der
Zuschnitt, für den Apple das Aktionsblatt vorsieht. Das frühere
+100
View File
@@ -129,3 +129,103 @@ export function markenlogoUrl(): string | undefined {
if (!basis) return undefined
return `${basis}/audi_dashboard_static/bilder/shell-logo.svg`
}
/* Ein Fahrzeugfoto wird im Browser verkleinert, bevor es hochgeladen wird.
WARUM: der Upload läuft als Dienstaufruf über die WebSocket-Verbindung, und
die hat eine Obergrenze je Nachricht - aiohttps Vorgabe sind 4 MiB, und
Base64 bläht die Datei vorher um ein Drittel auf. Über die reale Instanz
kommt noch Cloudflare samt Vorschaltserver dazu, die enger sein können.
Genau das war der Befund vom 03.09.2026: dasselbe Cockpit-Foto ging in der
Testinstanz (localhost) durch und scheiterte über die reale Instanz mit
"connection lost" - eine abgerissene Verbindung, kein Fehler des Backends,
deshalb auch keine brauchbare Meldung.
Verkleinern löst das an der Wurzel statt die Grenze zu verschieben: die
größte Fläche, auf der ein Foto je erscheint, ist die Inhaltsspalte im
Breitbildlayout mit 772px. 2000px lange Kante lassen dafür reichlich Luft
(auch für Bildschirme mit doppelter Punktdichte), und WebP bei 0,85 bringt
ein solches Foto typischerweise auf einige hundert Kilobyte.
Fällt irgendein Schritt aus (Format nicht dekodierbar, kein WebP-Encoder,
Ergebnis größer als das Original), bleibt es beim Original - schlechter als
vorher wird es dadurch nie. Wortgleich im Panel. */
const MAX_KANTE = 2000
const QUALITAET = 0.85
export function bildAlsBase64(blob: Blob): Promise<string> {
return new Promise((fertig, fehler) => {
const leser = new FileReader()
leser.onload = () => fertig(String(leser.result).split(",")[1] ?? "")
leser.onerror = () => fehler(leser.error)
leser.readAsDataURL(blob)
})
}
/* Formate, die jeder Browser dieser App wirklich anzeigen kann.
Wichtig, weil das Backend nichts prüft: `bilder.py` schreibt die Bytes roh
unter einen der elf festen `.webp`-Namen. Ein Foto, das der Browser nicht
umwandeln konnte und das er auch nicht anzeigen kann, läge danach als
Bilddatei auf der Platte, die niemand darstellt - ohne Meldung.
Der praktische Fall ist HEIC: die iPhone-Fotoauswahl liefert in der Regel
JPEG, am Mac im Finder gewählt kommt aber eine `.heic` an, und die kann
ausser Safari/WKWebView niemand dekodieren. */
const SICHERE_TYPEN = /^image\/(jpeg|png|webp|gif|bmp|avif)$/i
const SICHERE_ENDUNGEN = /\.(jpe?g|png|webp|gif|bmp|avif)$/i
export function bildformatIstSicher(datei: File): boolean {
// Der MIME-Typ zählt, wenn er da ist. Aus dem Explorer gezogene Dateien
// kommen je nach Quelle mit leerem `type` an - dann die Endung, dieselbe
// Doppelprüfung wie bei `istPdf()` im Panel.
if (datei.type) return SICHERE_TYPEN.test(datei.type)
return SICHERE_ENDUNGEN.test(datei.name)
}
export class BildformatFehler extends Error {}
export async function bildVerkleinern(datei: File): Promise<Blob> {
let klein: Blob | null = null
const adresse = URL.createObjectURL(datei)
try {
const bild = await new Promise<HTMLImageElement>((fertig, fehler) => {
const i = new Image()
i.onload = () => fertig(i)
i.onerror = () => fehler(new Error("Bild nicht lesbar"))
i.src = adresse
})
const kante = Math.max(bild.naturalWidth, bild.naturalHeight)
if (kante) {
const faktor = Math.min(1, MAX_KANTE / kante)
const flaeche = document.createElement("canvas")
flaeche.width = Math.round(bild.naturalWidth * faktor)
flaeche.height = Math.round(bild.naturalHeight * faktor)
flaeche.getContext("2d")?.drawImage(bild, 0, 0, flaeche.width, flaeche.height)
klein = await new Promise<Blob | null>((fertig) =>
flaeche.toBlob(fertig, "image/webp", QUALITAET),
)
}
} catch (fehler) {
console.warn("Bild konnte nicht umgewandelt werden", fehler)
} finally {
URL.revokeObjectURL(adresse)
}
// Ein bereits sparsam gespeichertes Foto soll nicht ein zweites Mal durch
// eine verlustbehaftete Kodierung - das kostet Qualität ohne Gewinn.
if (klein && klein.size < datei.size) return klein
/* Umwandeln ging nicht (oder brachte nichts) - dann muss wenigstens das
Original anzeigbar sein. Sonst lieber jetzt eine klare Meldung als
hinterher ein leeres Bild, dessen Ursache niemand mehr sieht. */
if (!bildformatIstSicher(datei)) {
const art = datei.type || datei.name.split(".").pop() || "unbekannt"
throw new BildformatFehler(
`Dieses Bildformat kann der Browser nicht umwandeln (${art}). ` +
"Bitte das Foto als JPEG, PNG oder WebP sichern und noch einmal versuchen.",
)
}
return datei
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Der Weg eines Termins auf dem Gerät.
*
* Geprüft wird die Entscheidung, nicht EventKit: dass ein natives Gerät das
* Plugin nimmt und NICHT das Teilen-Blatt (aus dem nie ein Kalender kam, siehe
* Kopf von kalender.ts), dass eine .ipa ohne das Plugin auf die Datei
* zurückfällt, und dass ein echter Fehler nicht hinter diesem Rückfall
* verschwindet.
*
* `@capacitor/*` wird ersetzt: sonst meldet `isNativePlatform()` "Browser" und
* die native Verzweigung wird nie betreten.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
const { nativ, dateien, teilen } = vi.hoisted(() => ({
nativ: { terminAnlegen: vi.fn() },
dateien: { writeFile: vi.fn() },
teilen: { share: vi.fn() },
}))
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => true },
registerPlugin: () => nativ,
}))
vi.mock("@capacitor/filesystem", () => ({
Filesystem: dateien,
Directory: { Cache: "CACHE" },
Encoding: { UTF8: "utf8" },
}))
vi.mock("@capacitor/share", () => ({ Share: teilen }))
/** Was Capacitor wirft, wenn die installierte App das Plugin nicht kennt. */
function pluginFehltFehler(): Error {
const fehler = new Error('KalenderTermin does not have an implementation of "terminAnlegen".')
;(fehler as Error & { code: string }).code = "UNIMPLEMENTED"
return fehler
}
beforeEach(() => {
vi.clearAllMocks()
dateien.writeFile.mockResolvedValue({ uri: "file:///cache/termin.ics" })
teilen.share.mockResolvedValue(undefined)
})
describe("Termin an den Kalender übergeben", () => {
it("nimmt auf dem Gerät das Plugin und nicht das Teilen-Blatt", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "gespeichert" })
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("RS4: Ölwechsel", "2027-03-14", "Musterstr. 1", "Autohaus Muster")
expect(nativ.terminAnlegen).toHaveBeenCalledWith({
titel: "RS4: Ölwechsel",
isoTag: "2027-03-14",
beschreibung: "Musterstr. 1",
ort: "Autohaus Muster",
})
expect(teilen.share).not.toHaveBeenCalled()
expect(dateien.writeFile).not.toHaveBeenCalled()
})
/* Ein Abbruch im System-Editor ist kein Fehler - die Funktion kehrt still
zurück, damit die Oberfläche nichts Rotes zeigt. */
it("behandelt einen Abbruch wie einen Erfolg", async () => {
nativ.terminAnlegen.mockResolvedValue({ status: "abgebrochen" })
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).resolves.toBeUndefined()
expect(teilen.share).not.toHaveBeenCalled()
})
it("fällt auf die Datei zurück, wenn die App das Plugin nicht kennt", async () => {
nativ.terminAnlegen.mockRejectedValue(pluginFehltFehler())
const { terminUebernehmen } = await import("./kalender")
await terminUebernehmen("Audi RS4: Inspektion", "2027-01-01")
expect(dateien.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "audi-rs4-inspektion.ics" }),
)
expect(teilen.share).toHaveBeenCalledWith(
expect.objectContaining({ files: ["file:///cache/termin.ics"] }),
)
})
/* Der Rückfall gilt NUR für die fehlende Umsetzung. Sonst verschwände ein
echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
Kalender fände. */
it("reicht einen echten Fehler durch, statt zu teilen", async () => {
nativ.terminAnlegen.mockRejectedValue(new Error("Kein Fenster für den Kalender-Editor."))
const { terminUebernehmen } = await import("./kalender")
await expect(terminUebernehmen("Reifenwechsel", "2026-10-15")).rejects.toThrow(
"Kein Fenster für den Kalender-Editor.",
)
expect(teilen.share).not.toHaveBeenCalled()
})
})
+81 -33
View File
@@ -1,26 +1,59 @@
/**
* Termine als Kalenderdatei. Portiert aus `ics()` im alten Panel.
* Termine an den Kalender übergeben.
*
* Erzeugt eine .ics-Datei ohne Server, mit Erinnerung zwei Tage vorher.
* DREI WEGE, WEIL DREI UMGEBUNGEN
* -------------------------------
* **Auf dem Gerät** geht der Termin über EventKit direkt in den Kalender
* (native/KalenderTermin.swift): der System-Editor erscheint fertig ausgefüllt,
* ein Tipp auf „Hinzufügen" genügt.
*
* ZWEI WEGE, WEIL EIN DOWNLOAD IN DER APP NICHT ANKOMMT
* -----------------------------------------------------
* Im Browser und im HA-Panel genügt ein `<a download>` mit einer
* Blob-Adresse. In der nativen Hülle nicht: **WKWebView ignoriert das
* `download`-Attribut**, der Klick läuft ins Leere und es passiert sichtbar
* nichts — genau so gemeldet („Wechseltermin in Kalender übernehmen klappt
* nicht", 2026-08-31).
* Vorher lief auch dort der Datei-Weg, und der konnte nie ankommen. Nicht wegen
* der Datei: **Apples Kalender-App meldet sich beim System gar nicht als
* Teilen-Ziel an**, für kein Format — das Teilen-Blatt kann sie deshalb
* überhaupt nicht anbieten. Dass ein per Mail geschickter Anhang funktioniert,
* führt in die Irre; dort öffnet die Dokumentvorschau des Systems (Quick Look)
* mit ihrem eigenen „Alle hinzufügen", nicht das Teilen-Blatt. Gemeldet vom
* Eigentümer am 02.09.2026, nachdem der Dateiname am selben Tag repariert war
* und es trotzdem nicht half.
*
* Auf dem Gerät geht die Datei deshalb ins temporäre Verzeichnis und von
* dort ins iOS-Teilen-Blatt, aus dem „Zu Kalender hinzufügen" kommt. Das ist
* auch der ehrlichere Ablauf: man sieht, wohin der Termin geht, statt dass
* eine Datei stillschweigend irgendwo landet.
* **Der Datei-Weg bleibt als Rückfall** für eine .ipa von vor dem 03.09.2026,
* die das Plugin noch nicht kennt. Ein Kalender steht darin weiterhin nicht zur
* Auswahl, aber die Datei lässt sich in „Dateien" sichern und von dort öffnen —
* besser als ein Knopf, der gar nichts tut.
*
* **Im Browser und im HA-Panel** bleibt es beim gewöhnlichen Download; dort
* übernimmt das Betriebssystem die .ics wie jede andere Datei. (In der nativen
* Hülle ginge das nicht: **WKWebView ignoriert das `download`-Attribut**, der
* Klick lief sichtbar ins Leere — so gemeldet am 2026-08-31.)
*/
import { Capacitor } from "@capacitor/core"
import { Capacitor, registerPlugin } from "@capacitor/core"
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem"
import { Share } from "@capacitor/share"
interface KalenderTermin {
/** `status`: "gespeichert" oder "abgebrochen" - ein Abbruch ist kein Fehler. */
terminAnlegen(angaben: {
titel: string
isoTag: string
beschreibung: string
ort: string
}): Promise<{ status: string }>
}
/** Muss zu `jsName` in KalenderTermin.swift passen. */
const Nativ = registerPlugin<KalenderTermin>("KalenderTermin")
/**
* Unterscheidet „dieses Gerät kennt das Plugin nicht" von einem echten Fehler.
* Nur im ersten Fall ist der Rückfall auf die Datei richtig - sonst verschwände
* ein echtes Problem hinter einem Teilen-Blatt, aus dem der Termin nie in den
* Kalender fände.
*/
function pluginFehlt(fehler: unknown): boolean {
return (fehler as { code?: string } | null)?.code === "UNIMPLEMENTED"
}
function zeilenUmbruch(text: string): string {
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n")
}
@@ -104,37 +137,52 @@ function dateiname(titel: string): string {
return `${stamm || "termin"}.ics`
}
export async function kalenderDateiLaden(
/** Der Rückfall für Geräte ohne das Plugin - siehe Kopf dieser Datei. */
async function ueberTeilenBlatt(
titel: string,
isoTag: string,
beschreibung: string,
ort: string,
): Promise<void> {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: dateiname(titel),
data: kalenderDatei(titel, isoTag, beschreibung, ort),
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
}
export async function terminUebernehmen(
titel: string,
isoTag: string,
beschreibung = "",
ort = "",
): Promise<void> {
const inhalt = kalenderDatei(titel, isoTag, beschreibung, ort)
const name = dateiname(titel)
if (Capacitor.isNativePlatform()) {
/* Cache statt Documents: der Termin ist im Kalender aufgehoben, sobald er
dort ist - die Zwischendatei muss nicht überleben, und iOS darf den
Cache jederzeit räumen. */
const geschrieben = await Filesystem.writeFile({
path: name,
data: inhalt,
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
/* files statt url: beides landet im Plugin als URL in den activityItems,
aber `files` ist der dokumentierte Weg für lokale Dateien - `url` ist
für Links gedacht. dialogTitle ist Android-only und schadet hier nicht. */
await Share.share({ title: titel, files: [geschrieben.uri], dialogTitle: titel })
try {
await Nativ.terminAnlegen({ titel, isoTag, beschreibung, ort })
return
} catch (fehler) {
if (!pluginFehlt(fehler)) throw fehler
}
await ueberTeilenBlatt(titel, isoTag, beschreibung, ort)
return
}
const blob = new Blob([inhalt], { type: "text/calendar;charset=utf-8" })
const blob = new Blob([kalenderDatei(titel, isoTag, beschreibung, ort)], {
type: "text/calendar;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const verweis = document.createElement("a")
verweis.href = url
verweis.download = name
verweis.download = dateiname(titel)
document.body.appendChild(verweis)
verweis.click()
verweis.remove()