}> */ private array $eintraege = []; public function __construct(string $inhalt) { $this->lies($inhalt); } public static function ausDatei(string $pfad): self { if (!is_readable($pfad)) { throw new \RuntimeException('Die Datei ist nicht lesbar: ' . $pfad); } $inhalt = file_get_contents($pfad); if ($inhalt === false) { throw new \RuntimeException('Die Datei konnte nicht gelesen werden: ' . $pfad); } return new self($inhalt); } /** * @return list}> */ public function eintraege(?string $typ = null): array { if ($typ === null) { return $this->eintraege; } return array_values(array_filter( $this->eintraege, static fn(array $e): bool => $e['typ'] === $typ )); } public function datumsformat(): string { return $this->datumsformat; } /** Wie viele Einträge je Sektionstyp? Grundlage der Import-Vorschau. */ public function zusammenfassung(): array { $zaehler = []; foreach ($this->eintraege as $eintrag) { $zaehler[$eintrag['typ']] = ($zaehler[$eintrag['typ']] ?? 0) + 1; } return $zaehler; } /** * Erster Wert eines Feldes, oder null. * * @param array{zeilen: list} $eintrag */ public static function feld(array $eintrag, string $code): ?string { foreach ($eintrag['zeilen'] as [$c, $wert]) { if ($c === $code) { return $wert; } } return null; } /** * Alle Werte eines Feldes – Splitzeilen und Adressen kommen mehrfach vor. * * @param array{zeilen: list} $eintrag * @return list */ public static function felder(array $eintrag, string $code): array { $werte = []; foreach ($eintrag['zeilen'] as [$c, $wert]) { if ($c === $code) { $werte[] = $wert; } } return $werte; } /** Ist das Feld vorhanden? Bei Kategorien sind "I" und "E" reine Marker ohne Wert. */ public static function hatFeld(array $eintrag, string $code): bool { foreach ($eintrag['zeilen'] as [$c, $_]) { if ($c === $code) { return true; } } return false; } /** * Beträge stehen im US-Format: "-1,241.04". Das Komma ist Tausendertrenner, * der Punkt trennt die Nachkommastellen. */ public static function betrag(?string $wert): float { if ($wert === null) { return 0.0; } $bereinigt = str_replace([',', ' ', "\u{00A0}"], '', trim($wert)); return $bereinigt === '' ? 0.0 : (float)$bereinigt; } /** * Datum nach ISO. Der Export nennt das Format im Kopf ("!Option:MDY"), die * Jahreszahl ist zweistellig: "D3.18.26" ist der 18.03.2026. Zweistellige * Jahre ab 70 gelten als 19xx – Quicken-Konvention, und private Buchungen * aus den Sechzigern gibt es nicht. */ public static function datum(?string $wert, string $format = 'MDY'): ?string { if ($wert === null) { return null; } $teile = preg_split('/[.\/\-]/', trim($wert)); if ($teile === false || count($teile) !== 3) { return null; } [$a, $b, $c] = array_map(static fn(string $t): int => (int)preg_replace('/\D/', '', $t), $teile); if ($format === 'DMY') { [$tag, $monat, $jahr] = [$a, $b, $c]; } else { [$monat, $tag, $jahr] = [$a, $b, $c]; } if ($jahr < 100) { $jahr += $jahr >= 70 ? 1900 : 2000; } if (!checkdate($monat, $tag, $jahr)) { return null; } return sprintf('%04d-%02d-%02d', $jahr, $monat, $tag); } private function lies(string $inhalt): void { $inhalt = self::nachUtf8($inhalt); $zeilen = explode("\n", str_replace(["\r\n", "\r"], "\n", $inhalt)); $typ = ''; $aktuellesKonto = null; $zeilenPuffer = []; foreach ($zeilen as $zeile) { $zeile = rtrim($zeile); if ($zeile === '') { continue; } if ($zeile[0] === '!') { $neuerTyp = $this->steuerzeile($zeile); if ($neuerTyp !== null) { $typ = $neuerTyp; } continue; } if ($zeile[0] === '^') { if ($zeilenPuffer !== []) { // Jeder Kontoeintrag setzt zugleich das aktuelle Konto. In // der AutoSwitch-Liste folgen mehrere aufeinander, ohne dass // Buchungen dazwischenstehen – das schadet nicht, deckt aber // die zweite Schreibweise mit ab, bei der ein einzelner // "!Account" den Kontowechsel bedeutet. if ($typ === self::TYP_KONTO) { $aktuellesKonto = self::feld(['zeilen' => $zeilenPuffer], 'N'); } $this->eintraege[] = [ 'typ' => $typ, 'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto, 'zeilen' => $zeilenPuffer, ]; $zeilenPuffer = []; } continue; } $zeilenPuffer[] = [$zeile[0], substr($zeile, 1)]; } // Ein fehlendes abschließendes "^" darf den letzten Eintrag nicht schlucken. if ($zeilenPuffer !== []) { $this->eintraege[] = [ 'typ' => $typ, 'konto' => $typ === self::TYP_KONTO ? null : $aktuellesKonto, 'zeilen' => $zeilenPuffer, ]; } } /** Wertet eine "!"-Zeile aus und liefert den neuen Sektionstyp, falls einer folgt. */ private function steuerzeile(string $zeile): ?string { $zeile = trim($zeile); if (str_starts_with($zeile, '!Option:')) { $option = trim(substr($zeile, 8)); if (in_array($option, ['MDY', 'DMY', 'YMD'], true)) { $this->datumsformat = $option; } return null; // AutoSwitch ändert den Sektionstyp nicht. } if (str_starts_with($zeile, '!Clear:')) { return null; } if (str_starts_with($zeile, '!Type:')) { return trim(substr($zeile, 6)); } if (str_starts_with($zeile, '!Account')) { return self::TYP_KONTO; } return null; } /** * Der Export ist ISO-8859-1. Läge schon UTF-8 vor (etwa weil die Datei * unterwegs konvertiert wurde), würde eine zweite Konvertierung die Umlaute * zerstören – deshalb erst prüfen. */ public static function nachUtf8(string $inhalt): string { if (mb_check_encoding($inhalt, 'UTF-8')) { return $inhalt; } return mb_convert_encoding($inhalt, 'UTF-8', 'ISO-8859-1'); } }