parseLines($contents) as $line) { [$name, $params, $value] = $line; match ($name) { 'UID' => $uid = $uid ?? $this->cleanValue($value), 'FN' => $fullName = $fullName ?? $this->cleanValue($value), 'N' => [$lastName, $firstName] = $this->parseStructuredName($value, $lastName, $firstName), 'ORG' => $organization = $organization ?? $this->cleanValue($this->splitEscaped($value, ';')[0] ?? ''), 'EMAIL' => $email = $email ?? $this->cleanValue($value), 'TEL' => $phoneNumbers[] = new ParsedVCardPhoneNumber($this->cleanValue($value), $this->phoneLabel($params)), 'CATEGORIES' => $categories = array_merge($categories, $this->parseCategories($value)), default => null, }; } $categories = array_values(array_unique(array_filter($categories))); return new ParsedVCard( uid: $uid, fullName: $fullName, firstName: $firstName, lastName: $lastName, organization: $organization, email: $email, phoneNumbers: array_values(array_filter( $phoneNumbers, fn (ParsedVCardPhoneNumber $phoneNumber): bool => $phoneNumber->rawNumber !== '', )), categories: $categories, ); } /** * @return array>, 2: string}> */ private function parseLines(string $contents): array { $contents = str_replace(["\r\n", "\r"], "\n", $contents); $contents = preg_replace("/\n[ \t]/", '', $contents) ?? $contents; $lines = []; foreach (explode("\n", $contents) as $line) { $line = trim($line); if ($line === '' || ! str_contains($line, ':')) { continue; } [$left, $value] = explode(':', $line, 2); $propertyParts = explode(';', $left); $name = strtoupper((string) array_shift($propertyParts)); if ($name === 'BEGIN' || $name === 'END' || $name === 'VERSION') { continue; } $params = []; foreach ($propertyParts as $part) { if (str_contains($part, '=')) { [$paramName, $paramValue] = explode('=', $part, 2); $paramName = strtoupper($paramName); $params[$paramName] = array_merge( $params[$paramName] ?? [], array_map('strtoupper', $this->splitEscaped($paramValue, ',')), ); continue; } $params['TYPE'][] = strtoupper($part); } $lines[] = [$name, $params, $value]; } return $lines; } /** * @return array{0: ?string, 1: ?string} */ private function parseStructuredName(string $value, ?string $currentLastName, ?string $currentFirstName): array { $parts = $this->splitEscaped($value, ';'); $lastName = $this->cleanValue($parts[0] ?? '') ?: $currentLastName; $firstName = $this->cleanValue($parts[1] ?? '') ?: $currentFirstName; return [$lastName, $firstName]; } /** * @return array */ private function parseCategories(string $value): array { return array_map( fn (string $category): string => $this->cleanValue($category), $this->splitEscaped($value, ','), ); } /** * @param array> $params */ private function phoneLabel(array $params): ?string { $types = array_filter($params['TYPE'] ?? []); if ($types === []) { return null; } return implode(', ', array_map( fn (string $type): string => ucfirst(strtolower($type)), array_unique($types), )); } /** * @return array */ private function splitEscaped(string $value, string $separator): array { $parts = []; $buffer = ''; $escaped = false; foreach (str_split($value) as $char) { if ($escaped) { $buffer .= '\\'.$char; $escaped = false; continue; } if ($char === '\\') { $escaped = true; continue; } if ($char === $separator) { $parts[] = $buffer; $buffer = ''; continue; } $buffer .= $char; } if ($escaped) { $buffer .= '\\'; } $parts[] = $buffer; return $parts; } private function cleanValue(string $value): string { $value = str_replace( ['\n', '\N', '\;', '\,', '\\\\'], ["\n", "\n", ';', ',', '\\'], trim($value), ); return trim($value); } }