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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user