diff --git a/packages/formatter/README.md b/packages/formatter/README.md index d4af6179b..0a1b01c2e 100644 --- a/packages/formatter/README.md +++ b/packages/formatter/README.md @@ -82,6 +82,146 @@ The `$locale->decimal(123.454321)` is: Please check [source code](./src/Formatter.php) to see available methods and [config example](defaults/config.php) to available settings 🤗 +# Formats + +Some methods like as `date()`/`datetime()`/etc have `$format` argument. The argument specifies not the format but the format name. So you can use the names and do not worry about real formats. It is very important when application big/grow. To specify available names and formats the package config should be published and customized. + +```shell +php artisan vendor:publish --provider=LastDragon_ru\\LaraASP\\Formatter\\Provider --tag=config +``` + +[include:example]: ./docs/Examples/Config.php +[//]: # (start: a0315c77f2fd2868ad7a67f118ff4816a93add9ae6e7d35899828ddc32cfac37) +[//]: # (warning: Generated automatically. Do not edit.) + +```php + [ + Formatter::Date => 'default', + ], + 'all' => [ + Formatter::Date => [ + 'default' => 'd MMM yyyy', + 'custom' => 'yyyy/MM/dd', + ], + ], + 'locales' => [ + 'ru_RU' => [ + Formatter::Date => [ + 'custom' => 'dd.MM.yyyy', + ], + ], + ], +]); + +$datetime = Date::make('2023-12-30T20:41:40.000018+04:00'); +$default = Container::getInstance()->make(Formatter::class); +$locale = $default->forLocale('ru_RU'); + +Example::dump($default->date($datetime)); +Example::dump($default->date($datetime, 'custom')); +Example::dump($locale->date($datetime)); +Example::dump($locale->date($datetime, 'custom')); +``` + +The `$default->date($datetime)` is: + +```plain +"30 Dec 2023" +``` + +The `$default->date($datetime, 'custom')` is: + +```plain +"2023/12/30" +``` + +The `$locale->date($datetime)` is: + +```plain +"30 дек. 2023" +``` + +The `$locale->date($datetime, 'custom')` is: + +```plain +"30.12.2023" +``` + +[//]: # (end: a0315c77f2fd2868ad7a67f118ff4816a93add9ae6e7d35899828ddc32cfac37) + +# Duration + +To format duration you can use built-in Intl formatter, but it doesn't support fraction seconds and have different format between locales (for example, `12345` seconds is `3:25:45` in `en_US` locale, and `12 345` in `ru_RU`). These reasons make difficult to use it in real applications. To make `duration()` more useful, the alternative syntax was added. + +[include:docblock]: ./src/Utils/DurationFormatter.php +[//]: # (start: 8e359fc1ea71d4c4b58c4acdcd3289f180a89cbd39ebdbd10422908bd66b0268) +[//]: # (warning: Generated automatically. Do not edit.) + +The syntax is the same as [ICU Date/Time format syntax](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + +| Symbol | Meaning | +|--------|-------------------------------| +| `y` | years | +| `M` | months | +| `d` | days | +| `H` | hours | +| `m` | minutes | +| `s` | seconds | +| `S` | fractional seconds | +| `z` | negative sign (default `-`) | +| `'` | escape for text | +| `''` | two single quotes produce one | + +[//]: # (end: 8e359fc1ea71d4c4b58c4acdcd3289f180a89cbd39ebdbd10422908bd66b0268) + +[include:example]: ./docs/Examples/Duration.php +[//]: # (start: bb574f6b1315aa7b33a56d897b23ecc4d18dece9ea201b85b54154e144931d3b) +[//]: # (warning: Generated automatically. Do not edit.) + +```php +make(Formatter::class); // For default app locale +$locale = $default->forLocale('ru_RU'); // For ru_RU locale + +Example::dump($default->duration(123.454321)); +Example::dump($locale->duration(123.4543)); +Example::dump($locale->duration(1_234_543)); +``` + +The `$default->duration(123.454321)` is: + +```plain +"00:02:03.454" +``` + +The `$locale->duration(123.4543)` is: + +```plain +"00:02:03.454" +``` + +The `$locale->duration(1234543)` is: + +```plain +"342:55:43.000" +``` + +[//]: # (end: bb574f6b1315aa7b33a56d897b23ecc4d18dece9ea201b85b54154e144931d3b) + [include:file]: ../../docs/Shared/Contributing.md [//]: # (start: 057ec3a599c54447e95d6dd2e9f0f6a6621d9eb75446a5e5e471ba9b2f414b89) [//]: # (warning: Generated automatically. Do not edit.) diff --git a/packages/formatter/defaults/config.php b/packages/formatter/defaults/config.php index b4adb6479..36216dbdc 100644 --- a/packages/formatter/defaults/config.php +++ b/packages/formatter/defaults/config.php @@ -2,6 +2,7 @@ use LastDragon_ru\LaraASP\Core\Utils\ConfigMerger; use LastDragon_ru\LaraASP\Formatter\Formatter; +use LastDragon_ru\LaraASP\Formatter\Utils\DurationFormatter; /** * ----------------------------------------------------------------------------- @@ -36,6 +37,12 @@ */ // Formatter::Time => 'custom', + /** + * Default custom duration format name, you can also use + * {@link NumberFormatter::DURATION} for built-in Intl format. + */ + // Formatter::Duration => 'custom', + /** * Global Attributes for {@link NumberFormatter::setAttribute} */ @@ -79,6 +86,15 @@ // 'custom' => 'HH:mm:ss.SSS', // ], + /** + * Custom duration format for all locales + * + * @see DurationFormatter + */ + // Formatter::Duration => [ + // 'custom' => 'HH:mm:ss.SSS', + // ], + /** * Intl properties for all locales (will be merged with `options`) */ @@ -107,6 +123,11 @@ // 'custom' => 'HH:mm:ss', // ], // + // // Custom duration format for specific Locale + // Formatter::Duration => [ + // 'custom' => 'HH:mm:ss', + // ], + // // // Intl properties for specific Locale (will be merged with all`) // Formatter::Decimal => [ // Formatter::IntlSymbols => [], diff --git a/packages/formatter/docs/Examples/Config.php b/packages/formatter/docs/Examples/Config.php new file mode 100644 index 000000000..de27ed829 --- /dev/null +++ b/packages/formatter/docs/Examples/Config.php @@ -0,0 +1,35 @@ + [ + Formatter::Date => 'default', + ], + 'all' => [ + Formatter::Date => [ + 'default' => 'd MMM yyyy', + 'custom' => 'yyyy/MM/dd', + ], + ], + 'locales' => [ + 'ru_RU' => [ + Formatter::Date => [ + 'custom' => 'dd.MM.yyyy', + ], + ], + ], +]); + +$datetime = Date::make('2023-12-30T20:41:40.000018+04:00'); +$default = Container::getInstance()->make(Formatter::class); +$locale = $default->forLocale('ru_RU'); + +Example::dump($default->date($datetime)); +Example::dump($default->date($datetime, 'custom')); +Example::dump($locale->date($datetime)); +Example::dump($locale->date($datetime, 'custom')); diff --git a/packages/formatter/docs/Examples/Config.run b/packages/formatter/docs/Examples/Config.run new file mode 100644 index 000000000..aefd0de01 --- /dev/null +++ b/packages/formatter/docs/Examples/Config.run @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +"${BASH_SOURCE%/*}/../../../../dev/artisan" dev:example "${BASH_SOURCE%.*}.php" diff --git a/packages/formatter/docs/Examples/Duration.php b/packages/formatter/docs/Examples/Duration.php new file mode 100644 index 000000000..83d186dd3 --- /dev/null +++ b/packages/formatter/docs/Examples/Duration.php @@ -0,0 +1,12 @@ +make(Formatter::class); // For default app locale +$locale = $default->forLocale('ru_RU'); // For ru_RU locale + +Example::dump($default->duration(123.454321)); +Example::dump($locale->duration(123.4543)); +Example::dump($locale->duration(1_234_543)); diff --git a/packages/formatter/docs/Examples/Duration.run b/packages/formatter/docs/Examples/Duration.run new file mode 100755 index 000000000..aefd0de01 --- /dev/null +++ b/packages/formatter/docs/Examples/Duration.run @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +"${BASH_SOURCE%/*}/../../../../dev/artisan" dev:example "${BASH_SOURCE%.*}.php" diff --git a/packages/formatter/src/Formatter.php b/packages/formatter/src/Formatter.php index bd9bcfd4e..bfd85674d 100644 --- a/packages/formatter/src/Formatter.php +++ b/packages/formatter/src/Formatter.php @@ -3,6 +3,7 @@ namespace LastDragon_ru\LaraASP\Formatter; use Closure; +use DateInterval; use DateTimeInterface; use DateTimeZone; use Illuminate\Container\Container; @@ -15,6 +16,7 @@ use LastDragon_ru\LaraASP\Formatter\Exceptions\FailedToCreateDateFormatter; use LastDragon_ru\LaraASP\Formatter\Exceptions\FailedToCreateNumberFormatter; use LastDragon_ru\LaraASP\Formatter\Exceptions\FailedToFormatValue; +use LastDragon_ru\LaraASP\Formatter\Utils\DurationFormatter; use Locale; use NumberFormatter; use OutOfBoundsException; @@ -100,13 +102,18 @@ class Formatter { public const Ordinal = 'ordinal'; /** - * Options: - * - none + * Options (one of): + * - `int`: {@link NumberFormatter::DURATION} + * - `string`: the name of custom format * * Locales options: * - {@link Formatter::IntlTextAttributes} * - {@link Formatter::IntlAttributes} * - {@link Formatter::IntlSymbols} + * - `string`: available only for custom formats: locale specific pattern + * (key: `locales..duration.` and/or default pattern + * (key: `all.duration.`) + * {@link DurationFormatter} */ public const Duration = 'duration'; @@ -313,8 +320,22 @@ public function ordinal(?int $value): string { return $this->formatValue(static::Ordinal, $value); } - public function duration(?int $value): string { - return $this->formatValue(static::Duration, $value); + public function duration(DateInterval|float|int|null $value, string $format = null): string { + $type = static::Duration; + $format = $format ?: $this->getOptions($type); + $format = is_string($format) + ? Cast::toString($this->getLocaleOptions($type, $format)) + : $format; + $format ??= 'HH:mm:ss.SSS'; + $value ??= 0; + $value = $value instanceof DateInterval + ? DurationFormatter::getTimestamp($value) + : $value; + $value = is_string($format) + ? (new DurationFormatter($format))->format($value) + : $this->formatValue($type, $value); + + return $value; } public function time( diff --git a/packages/formatter/src/FormatterTest.php b/packages/formatter/src/FormatterTest.php index 0bef22630..85599f2a1 100644 --- a/packages/formatter/src/FormatterTest.php +++ b/packages/formatter/src/FormatterTest.php @@ -126,10 +126,30 @@ public function testPercentConfig(): void { } public function testDuration(): void { + self::assertEquals('03:25:45.120', $this->formatter->duration(12_345.12)); + self::assertEquals('03:25:45.001', $this->formatter->forLocale('ru_RU')->duration(12_345.0005)); + } + + public function testDurationConfig(): void { + config([ + Package::Name.'.options.'.Formatter::Duration => NumberFormatter::DURATION, + ]); + self::assertEquals('3:25:45', $this->formatter->duration(12_345)); self::assertEquals("12\u{00A0}345", $this->formatter->forLocale('ru_RU')->duration(12_345)); } + public function testDurationCustomFormat(): void { + config([ + Package::Name.'.options.'.Formatter::Duration => 'custom', + Package::Name.'.all.'.Formatter::Duration.'.custom' => 'mm:ss', + Package::Name.'.all.'.Formatter::Duration.'.custom2' => 'H:mm:ss.SSS', + ]); + + self::assertEquals('02:03', $this->formatter->duration(123.456)); + self::assertEquals('0:02:03.456', $this->formatter->duration(123.456, 'custom2')); + } + public function testTime(): void { $time = DateTime::createFromFormat('H:i:s', '23:24:59') ?: null; diff --git a/packages/formatter/src/Utils/DurationFormatter.php b/packages/formatter/src/Utils/DurationFormatter.php new file mode 100644 index 000000000..d16f0136b --- /dev/null +++ b/packages/formatter/src/Utils/DurationFormatter.php @@ -0,0 +1,146 @@ +invert ? -1 : 1) * (0 + + $interval->y * self::SecondsInYear + + $interval->m * self::SecondsInMonth + + $interval->d * self::SecondsInDay + + $interval->h * self::SecondsInHour + + $interval->i * self::SecondsInMinute + + $interval->s + + $interval->f); + } + + public function format(float|int $value): string { + $formatted = ''; + $tokens = iterator_to_array(new UnicodeDateTimeFormatParser($this->pattern)); + $units = $this->prepare($tokens, abs($value), [ + 'y' => self::SecondsInYear, + 'M' => self::SecondsInMonth, + 'd' => self::SecondsInDay, + 'H' => self::SecondsInHour, + 'm' => self::SecondsInMinute, + 's' => 1, + 'S' => null, + ]); + + foreach ($tokens as $token) { + $formatted .= match ($token->pattern) { + 'z' => $value < 0 ? '-' : '', + "'" => $token->value, + default => isset($units[$token->pattern]) + ? $this->value($units[$token->pattern], mb_strlen($token->value)) + : '', + }; + } + + return $formatted; + } + + /** + * @param array $tokens + * @param array $units + * + * @return array + */ + private function prepare(array $tokens, float|int $value, array $units): array { + // Calculate values + $values = []; + $patterns = array_filter( + array_reduce( + $tokens, + static function (array $used, UnicodeDateTimeFormatToken $token) use ($units): array { + if (array_key_exists($token->pattern, $units)) { + $used[$token->pattern] = true; + } + + return $used; + }, + [], + ), + ); + + foreach ($units as $pattern => $multiplier) { + if (!isset($patterns[$pattern])) { + continue; + } + + if ($multiplier !== null) { + $values[$pattern] = (int) floor($value / $multiplier); + $value = $value - $values[$pattern] * $multiplier; + } else { + $values[$pattern] = $value; + $value = 0; + } + } + + // Return + return $values; + } + + private function value(float|int $value, int $length): string { + // Float? + if (is_float($value)) { + $value = (int) round(($value - (int) $value) * pow(10, $length)); + } + + // Width? + $value = str_pad((string) $value, $length, '0', STR_PAD_LEFT); + + // Return + return $value; + } +} diff --git a/packages/formatter/src/Utils/DurationFormatterTest.php b/packages/formatter/src/Utils/DurationFormatterTest.php new file mode 100644 index 000000000..d857acb86 --- /dev/null +++ b/packages/formatter/src/Utils/DurationFormatterTest.php @@ -0,0 +1,82 @@ + + // ========================================================================= + /** + * @dataProvider dataProviderGetTimestamp + */ + public function testGetTimestamp(float $expected, DateInterval $interval): void { + self::assertEquals($expected, DurationFormatter::getTimestamp($interval)); + } + + /** + * @dataProvider dataProviderFormat + */ + public function testFormat(string $expected, string $format, float|int $duration): void { + $formatter = new DurationFormatter($format); + $actual = $formatter->format($duration); + + self::assertEquals($expected, $actual); + } + // + + // + // ========================================================================= + /** + * @return array + */ + public static function dataProviderGetTimestamp(): array { + return [ + 'a' => [ + 22 * 365 * 24 * 60 * 60 + 22 * 30 * 24 * 60 * 60 + 22 * 24 * 60 * 60 + 22 * 60 * 60 + 22 * 60 + 22, + new DateInterval('P22Y22M22DT22H22M22S'), + ], + 'b' => [ + -1 * (16 * 24 * 60 * 60 - 0.000484), + (new DateTime('2023-12-27T11:22:45.000121+04:00'))->diff( + new DateTime('2023-12-11T11:22:45.000605+04:00'), + ), + ], + ]; + } + + /** + * @return array + */ + public static function dataProviderFormat(): array { + $duration = static function (string $interval): float { + return DurationFormatter::getTimestamp(new DateInterval($interval)); + }; + + return [ + 'S' => ['3', 'S', 12.345678], + 'SS' => ['35', 'SS', 12.345678], + 'SSS' => ['346', 'SSS', 12.345678], + 's.SSS' => ['1.230', 's.SSS', 1.23], + 'ss.SS' => ['123.45', 'ss.SS', 123.45], + 'ss.SSS' => ['01.230', 'ss.SSS', 1.23], + 'm:ss' => ['3:00', 'm:ss', 180], + 'mm:ss' => ['03:00', 'mm:ss', -180], + 'zmm:ss' => ['-03:00', 'zmm:ss', -180], + 'H:m:s' => ['5:3:0', 'H:m:s', 5 * 60 * 60 + 180], + 'HH:mm:ss' => ['05:03:00', 'HH:mm:ss', 5 * 60 * 60 + 180], + 'y:M:d:H:m:s' => ['1:2:3:1:2:5', 'y:M:d:H:m:s', $duration('P1Y2M3DT1H2M5S')], + 'yyy:MM:dd:HH:mm:ss' => ['001:02:03:01:02:05', 'yyy:MM:dd:HH:mm:ss', $duration('P1Y2M3DT1H2M5S')], + "y:M:d:'H':m:s" => ['1:2:3:H:62:5', "y:M:d:'H':m:s", $duration('P1Y2M3DT1H2M5S')], + "y:'M':d:'H':m:s.SSS" => ['2:M:298:H:62:5.000', "y:'M':d:'H':m:s.SSS", $duration('P1Y22M3DT1H2M5S')], + ]; + } + // +} diff --git a/packages/formatter/src/Utils/UnicodeDateTimeFormatParser.php b/packages/formatter/src/Utils/UnicodeDateTimeFormatParser.php new file mode 100644 index 000000000..03d0e4411 --- /dev/null +++ b/packages/formatter/src/Utils/UnicodeDateTimeFormatParser.php @@ -0,0 +1,108 @@ + + */ +class UnicodeDateTimeFormatParser implements IteratorAggregate { + public function __construct( + protected readonly string $pattern, + ) { + // empty + } + + #[Override] + public function getIterator(): Traversable { + $text = null; + $escape = "'"; + $replace = [ + $escape.$escape => $escape, + ]; + $inEscape = false; + + foreach ($this->tokenize($this->pattern) as $token => $value) { + $isEscape = $token === $escape; + $isPattern = !$isEscape && !$inEscape && preg_match('/[a-z]+/i', $token); + + if ($inEscape) { + if ($isEscape) { + $value = mb_substr($value, 1); + $inEscape = mb_strlen($value) % 2 !== 0; + } + + $text .= $value; + } elseif ($isEscape) { + if (mb_strlen($value) % 2 !== 0) { + $text .= mb_substr($value, 1); + $inEscape = true; + } else { + $text .= $value; + } + } elseif ($isPattern) { + if ($text) { + yield new UnicodeDateTimeFormatToken($escape, strtr($text, $replace)); + + $text = null; + } + + yield new UnicodeDateTimeFormatToken($token, $value); + } else { + $text .= $value; + } + } + + if ($text) { + yield new UnicodeDateTimeFormatToken($escape, strtr($text, $replace)); + } + + yield from []; + } + + /** + * @return Iterator + */ + private function tokenize(string $pattern): Iterator { + // Split into char & string of the same chars + $strings = preg_split('/((.)\g{-1}*)/um', $pattern, flags: PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); + + if (!$strings) { + yield from []; + + return; + } + + // Group into char & string + $value = null; + + foreach ($strings as $string) { + if ($value === null) { + $value = $string; + } else { + yield $string => $value; + + $value = null; + } + } + } +} diff --git a/packages/formatter/src/Utils/UnicodeDateTimeFormatParserTest.php b/packages/formatter/src/Utils/UnicodeDateTimeFormatParserTest.php new file mode 100644 index 000000000..19eeb71dd --- /dev/null +++ b/packages/formatter/src/Utils/UnicodeDateTimeFormatParserTest.php @@ -0,0 +1,86 @@ + + // ========================================================================= + /** + * @dataProvider dataProviderGetIterator + * + * @param array $expected + */ + public function testGetIterator(array $expected, string $format): void { + $actual = iterator_to_array(new UnicodeDateTimeFormatParser($format)); + $actual = array_map(static fn (UnicodeDateTimeFormatToken $token) => [$token->pattern, $token->value], $actual); + + self::assertEquals($expected, $actual); + } + // + + // + // ========================================================================= + /** + * @return array, string}> + */ + public static function dataProviderGetIterator(): array { + return [ + 'a' => [[], ''], + 'b' => [ + [ + ["'", 'text'], + ], + "'text'", + ], + 'c' => [ + [ + ['H', 'HH'], + ["'", ':'], + ['m', 'mm'], + ["'", ':'], + ['s', 'ss'], + ["'", '.'], + ['S', 'SSS'], + ], + 'HH:mm:ss.SSS', + ], + 'd' => [ + [ + ['H', 'HH'], + ["'", ":'"], + ['m', 'mm'], + ["'", ":ss'"], + ], + "HH:''mm:'ss'''", + ], + 'e' => [ + [ + ["'", "''mm"], + ['s', 'sss'], + ["'", "'"], + ], + "'''''mm'sss''", + ], + 'f' => [ + [ + ["'", 'абвгдеё;%:'], + ['Y', 'Y'], + ["'", '😀'], + ['y', 'yyyy'], + ], + 'абвгдеё;%:Y😀yyyy', + ], + ]; + } + // +} diff --git a/packages/formatter/src/Utils/UnicodeDateTimeFormatToken.php b/packages/formatter/src/Utils/UnicodeDateTimeFormatToken.php new file mode 100644 index 000000000..fecf23eed --- /dev/null +++ b/packages/formatter/src/Utils/UnicodeDateTimeFormatToken.php @@ -0,0 +1,15 @@ +