diff --git a/README.md b/README.md index a4a9290..d07d27e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ composer require heimrichhannot/salutation-creator ## Requirements -- PHP ^8.1 +- PHP ^8.2 - symfony/translation-contracts ^1 || ^2 || ^3 ## Features @@ -22,6 +22,7 @@ composer require heimrichhannot/salutation-creator - ✅ Support for academic titles (Dr., Prof., etc.) - ✅ Gender-sensitive salutations - ✅ Prefix and suffix titles with priorities +- ✅ Name parsing from strings and arrays - ✅ Multilingual translations via Symfony Translation - ✅ Extensible with custom name, gender, and title classes @@ -58,9 +59,8 @@ $context = (new SalutationContext()) ->setGender(Gender::FEMALE) ->addTitle(new GermanDoctorTitle()) ->addTitle(new GermanProfessorTitle()) - ->addTitle(new PrefixTitle('Hero') + ->addTitle(new PrefixTitle('Hero')) ->addTitles(Titles::fromString('dr phd unknown')); // Adding titles from string - ; echo $salutationCreator->generate($context); @@ -84,6 +84,7 @@ Gender from string: use HeimrichHannot\SalutationCreator\SalutationCreator; use HeimrichHannot\SalutationCreator\SalutationContext; use HeimrichHannot\SalutationCreator\Context\Gender\Gender; +use HeimrichHannot\SalutationCreator\Context\Name\GermanTypeName; $data = [ 'firstname' => 'Lisa', @@ -93,12 +94,58 @@ $data = [ $salutationContext = (new SalutationContext()) ->setName(new GermanTypeName($data['firstname'], $data['lastname'])) - ->addTitles(Gender::tryFromString($data['gender']))); + ->setGender(Gender::tryFromString($data['gender'])); echo (new SalutationCreator($this->translator))->generate($salutationContext); // Outputs "Sehr geehrte Frau Müller" ``` +## Name Parsing + +Use the bundled name parsers to create `AbstractName` instances from strings or arrays before passing them to a `SalutationContext`. + +### Parse a name string + +```php +use HeimrichHannot\SalutationCreator\Context\Name\GermanTypeName; +use HeimrichHannot\SalutationCreator\Parser\Name\StringParser; + +$name = StringParser::create(GermanTypeName::class)->parse('Max Mustermann'); + +echo $name?->getFullName(); +// Output: "Max Mustermann" +``` + +The string parser supports common formats such as `Max Mustermann` and `Mustermann, Max`. For single-part names, parsing returns `null` by default because the result is incomplete. + +If incomplete names are acceptable for your use case, enable them explicitly: + +```php +$name = StringParser::create(GermanTypeName::class) + ->allowIncomplete(true) + ->parse('Mustermann'); + +echo $name?->getFormalName(); +// Output: "Mustermann" +``` + +### Parse name data from arrays + +```php +use HeimrichHannot\SalutationCreator\Context\Name\GermanTypeName; +use HeimrichHannot\SalutationCreator\Parser\Name\ArrayParser; + +$name = ArrayParser::create(GermanTypeName::class)->parse([ + 'firstname' => 'Max', + 'lastname' => 'Mustermann', +]); + +echo $name?->getFullName(); +// Output: "Max Mustermann" +``` + +For `GermanTypeName`, the array parser supports English keys (`firstname`, `lastname`, `name`) and German keys (`vorname`, `nachname`, `zuname`, `name`). Non-string values are ignored. + ## Translations / Translation files Since this library in framework-agnostic, you need to register the translation files manually or copy them to your project's translation directory. @@ -196,6 +243,11 @@ The following parameters are available in translations: - `GermanTypeName`: German names with first and last name +### Name Parser Classes + +- `StringParser`: Parses full names from plain strings +- `ArrayParser`: Parses first and last names from array data + ### Gender - `Gender::MALE` - Male @@ -310,4 +362,4 @@ class IcelandTypeName extends AbstractName return $this->firstName; } } -``` \ No newline at end of file +``` diff --git a/src/Context/Gender/Gender.php b/src/Context/Gender/Gender.php index 1c4a44d..0ab7c38 100644 --- a/src/Context/Gender/Gender.php +++ b/src/Context/Gender/Gender.php @@ -13,7 +13,7 @@ enum Gender: string implements GenderInterface public function build(): SalutationPartResult { return new SalutationPartResult( - 'gender.' . $this->value, + 'gender.'.$this->value, domain: 'salutation_creator', fallback: ucfirst($this->value), ); diff --git a/src/Context/Name/GermanTypeName.php b/src/Context/Name/GermanTypeName.php index 79d557f..e65dadf 100644 --- a/src/Context/Name/GermanTypeName.php +++ b/src/Context/Name/GermanTypeName.php @@ -5,14 +5,14 @@ class GermanTypeName extends AbstractName { public function __construct( - private readonly string $firstName, - private readonly string $lastName, + public string $firstName = '', + public string $lastName = '', ) { } public function getFullName(): string { - return $this->firstName . ' ' . $this->lastName; + return trim($this->firstName.' '.$this->lastName); } public function getFormalName(): string diff --git a/src/Parser/Name/AbstractParser.php b/src/Parser/Name/AbstractParser.php new file mode 100644 index 0000000..21851e4 --- /dev/null +++ b/src/Parser/Name/AbstractParser.php @@ -0,0 +1,79 @@ +[] $rules + * @param class-string $format + */ + final public function __construct( + protected array $rules, + protected string $format, + ) { + if (!is_a($this->format, AbstractName::class, true)) { + throw new \InvalidArgumentException(sprintf('Format class "%s" does not exist or is not a subclass of "%s".', $this->format, AbstractName::class)); + } + } + + /** + * @param class-string $format + */ + public static function create(string $format): static + { + static::validateFormat($format); + $rules = static::defaultRules($format); + + $instance = new static($rules, $format); + + return $instance; + } + + public function allowIncomplete(bool $allow): static + { + $this->allowIncomplete = $allow; + + return $this; + } + + public static function validateFormat(string $targetFormat): void + { + if (!class_exists($targetFormat) || !is_subclass_of($targetFormat, AbstractName::class)) { + throw new \InvalidArgumentException(sprintf('Target format class "%s" does not exist or is not a subclass of "%s".', $targetFormat, AbstractName::class)); + } + } + + protected function buildNameFromResult(Result $result): ?AbstractName + { + if (!$result->isComplete() && !$this->allowIncomplete) { + return null; + } + + if ($result->isEmpty()) { + return null; + } + + /** + * @todo We need to abstract the naming even more + */ + /** @var GermanTypeName $name */ + $name = new ($this->format)(); + $name->firstName = $result->firstName; + $name->lastName = $result->lastName; + + return $name; + } + + /** + * @return class-string[] + */ + abstract public static function defaultRules(string $format): array; +} diff --git a/src/Parser/Name/ArrayParser.php b/src/Parser/Name/ArrayParser.php new file mode 100644 index 0000000..c361f7d --- /dev/null +++ b/src/Parser/Name/ArrayParser.php @@ -0,0 +1,54 @@ + $data + */ + public function parse(array $data): ?AbstractName + { + $result = new Result(false); + foreach ($data as $key => $value) { + $entry = new RowValue($key, $value); + foreach ($this->rules as $rule) { + if (!is_a($rule, RuleInterface::class, true)) { + continue; + } + $result = $result->with((new $rule())->apply($entry)); + if ($result->isComplete()) { + break 2; + } + } + } + + return $this->buildNameFromResult($result); + } + + /** + * @param class-string $format + * + * @return class-string[] + */ + public static function defaultRules(string $format): array + { + return match ($format) { + GermanTypeName::class => [ + GermanKeyNameRule::class, + EnglishKeyNameRule::class, + ], + default => [ + EnglishKeyNameRule::class, + ], + }; + } +} diff --git a/src/Parser/Name/Rule/Array/EnglishKeyNameRule.php b/src/Parser/Name/Rule/Array/EnglishKeyNameRule.php new file mode 100644 index 0000000..22ac8ee --- /dev/null +++ b/src/Parser/Name/Rule/Array/EnglishKeyNameRule.php @@ -0,0 +1,31 @@ +originalValue)) { + return new Result(false); + } + + if ('firstname' === $value->normalizedKey) { + return new Result(true, firstName: $value->originalValue, origin: $value); + } + if (\in_array($value->normalizedKey, ['lastname', 'name'])) { + return new Result(true, lastName: $value->originalValue, origin: $value); + } + + return new Result(false); + } +} diff --git a/src/Parser/Name/Rule/Array/GermanKeyNameRule.php b/src/Parser/Name/Rule/Array/GermanKeyNameRule.php new file mode 100644 index 0000000..5a69408 --- /dev/null +++ b/src/Parser/Name/Rule/Array/GermanKeyNameRule.php @@ -0,0 +1,31 @@ +originalValue)) { + return new Result(false); + } + + if (in_array($value->normalizedKey, ['vorname'])) { + return new Result(true, firstName: $value->originalValue, origin: $value); + } + if (in_array($value->normalizedKey, ['nachname', 'zuname', 'name'])) { + return new Result(true, lastName: $value->originalValue, origin: $value); + } + + return new Result(false); + } +} diff --git a/src/Parser/Name/Rule/Result.php b/src/Parser/Name/Rule/Result.php new file mode 100644 index 0000000..0d70add --- /dev/null +++ b/src/Parser/Name/Rule/Result.php @@ -0,0 +1,38 @@ +matched) { + return $this; + } + + return new self( + true, + '' !== $result->firstName ? $result->firstName : $this->firstName, + '' !== $result->lastName ? $result->lastName : $this->lastName, + $result->origin + ); + } + + public function isComplete(): bool + { + return $this->matched && '' !== $this->firstName && '' !== $this->lastName; + } + + public function isEmpty(): bool + { + return '' === $this->firstName && '' === $this->lastName; + } +} diff --git a/src/Parser/Name/Rule/RowValue.php b/src/Parser/Name/Rule/RowValue.php new file mode 100644 index 0000000..d0e5797 --- /dev/null +++ b/src/Parser/Name/Rule/RowValue.php @@ -0,0 +1,19 @@ +originalKey = (string) $key; + $this->normalizedKey = strtolower((string) preg_replace('/[^a-z0-9]/i', '', $this->originalKey)); + } +} diff --git a/src/Parser/Name/Rule/RuleInterface.php b/src/Parser/Name/Rule/RuleInterface.php new file mode 100644 index 0000000..05c498d --- /dev/null +++ b/src/Parser/Name/Rule/RuleInterface.php @@ -0,0 +1,8 @@ +buildNameFromResult(new Result(true, $firstName, $lastName)); + } + + $parts = preg_split('/\s+/u', $name, -1, PREG_SPLIT_NO_EMPTY); + if (false === $parts || ($count = count($parts)) === 0) { + return null; + } + + if (1 === $count) { + return $this->buildNameFromResult(new Result(true, '', $parts[0])); + } + + if (2 === $count) { + return $this->buildNameFromResult(new Result(true, $parts[0], $parts[1])); + } + + $surnamePrefixes = [ + 'von', + 'vom', + 'van', + 'de', + 'del', + 'della', + 'der', + 'den', + 'zu', + 'zur', + 'zum', + 'da', + 'di', + 'du', + 'la', + 'le', + 'dos', + 'das', + 'do', + 'bin', + 'al', + ]; + + // Startpunkt des Nachnamens suchen + $lastNameStartIndex = $count - 1; + + while ( + $lastNameStartIndex > 0 + && in_array(mb_strtolower($parts[$lastNameStartIndex - 1]), $surnamePrefixes, true) + ) { + --$lastNameStartIndex; + } + + if ($lastNameStartIndex < $count - 1) { + return $this->buildNameFromResult( + new Result( + true, + implode(' ', array_slice($parts, 0, $lastNameStartIndex)), + implode(' ', array_slice($parts, $lastNameStartIndex)), + ) + ); + } + + // at least we have only wild guessing :) + $splitIndex = (int) ceil($count / 2); + + return $this->buildNameFromResult( + new Result( + true, + implode(' ', array_slice($parts, 0, $splitIndex)), + implode(' ', array_slice($parts, $splitIndex)) + ) + ); + } +} diff --git a/src/SalutationCreator.php b/src/SalutationCreator.php index c539195..13c22d8 100644 --- a/src/SalutationCreator.php +++ b/src/SalutationCreator.php @@ -20,9 +20,8 @@ public function generate(SalutationContext $context): string if (Position::PREFIX === $title->getPosition()) { $prefixTitleList[$title->getPriority()][] = $title; continue; - } else { - $suffixTitleList[$title->getPriority()][] = $title; } + $suffixTitleList[$title->getPriority()][] = $title; } $prefixTitles = $this->buildString($prefixTitleList); @@ -52,9 +51,7 @@ private function buildString(array $array): string { $string = ''; array_walk_recursive($array, function ($value) use (&$string) { - /** - * @var SalutationPartInterface $value - */ + /** @var SalutationPartInterface $value */ $string .= $value->build()->trans($this->translator); });