Долгая история про локализацию даты без года в PHP

в 8:09, , рубрики: date, i18n, Intl, php, time, Локализация продуктов

Давайте начнём с простой задачки — вывести локализованную дату: там должен быть день, полное название месяца на языке локали и полный год. В наше время это действительно очень просто. В PHP есть своё i18n-расширение intl, которое входит в ядро с версии 5.3. И в этом intl есть класс IntlDateFormatter, у которого в свою очередь предопределено несколько форматов. Используем его LONG формат.

<?php

foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $formatter = new IntlDateFormatter(
        $locale, 
        IntlDateFormatter::LONG, 
        IntlDateFormatter::NONE, 
        'Europe/Moscow'
    );
    echo $formatter->format(1455111783), PHP_EOL;
}

Результат:

February 10, 2016
10 февраля 2016 г.
10 de febrero de 2016
۱۰ ﻑﻭﺭیﻩٔ ۲۰۱۶ ﻡ. // вот тут вообще-то RTL-текст, но я хз как это правильно оформить

Пока неплохо. А теперь давайте слегка изменим условия: «вывести локализованную дату: там должен быть день и полное название месяца на языке локали». То есть, мы не хотим отображать год.

Желаемый результат выглядит так:

February 10
10 февраля
10 de febrero
۱۰ ﻑﻭﺭیﻩٔ ﻡ. // вот тут я не уверен, что это желаемый результат, но надеюсь, никто не заметит

Вообще-то, теперь задача уже не так проста, как кажется.

Честно говоря, весь этот пост будет только и исключительно об этой задаче.

Погоди-погоди, а тебе это вообще зачем?

Некоторые, наверное, могут удивиться, какого вообще мне понадобился именно такой формат? Это довольно очевидно, если вы работаете с какой-то системой, имеющей отображение в виде ленты событий с отметками времени. Но если нет, давайте просто взглянем на Твиттер.

twitter date formats

Если твит был опубликован недавно, Твиттер покажет вам грамотно отформатированный интервал времени. Если же твит случился давно, вы увидите грамотно отформатированную дату. Более того, если твит был опубликован в этом году, то года в этой дате не будет. И это отлично, это часть хорошего UX.

Идея в том, чтобы пользователи максимально быстро понимали, когда это случилось. Лишние данные это шум.

Теперь вы знаете, зачем. Вернёмся к нашей задаче.

ОК, значит для этого должен быть отдельный формат, типа LONG, но без года, так?

Не так. В IntlDateFormatter только четыре стандартных формата: FULL, LONG, MEDIUM и SHORT, и в каждом из них присутствует год.

<?php

$formats = [
    IntlDateFormatter::FULL, 
    IntlDateFormatter::LONG, 
    IntlDateFormatter::MEDIUM, 
    IntlDateFormatter::SHORT,   
];
foreach ($formats as $format) {
    $formatter = new IntlDateFormatter(
        'en_US', 
        $format, 
        IntlDateFormatter::NONE, 
        'Europe/Moscow'
    );
    echo $formatter->format(1455111783), PHP_EOL;
}

Результат:

Wednesday, February 10, 2016
February 10, 2016
Feb 10, 2016
2/10/16

Если вы немного подумаете, то станет ясно, что невозможно заранее определить константы для каждого кастомного формата, который когда-либо может прийти в голову разработчику, дизайнеру или менеджеру.

Ха, я только что придумал простое решение!

Да ладно? Дай угадаю: ты хочешь попросту вырезать год из того, что получится после форматирования с LONG, так? Готов спорить, что так. Без примеров может быть сложновато понять, в чём проблема. Давайте просто посмотрим.

Напоминаю, что мы имеем.

February 10, 2016
10 февраля 2016 г.
10 de febrero de 2016
۱۰ ﻑﻭﺭیﻩٔ ۲۰۱۶ ﻡ.

Вырезаем год.

February 10, 
10 февраля г.
10 de febrero de
۱۰ ﻑﻭﺭیﻩٔ ﻡ. 

Видите все эти остаточные артефакты типа , и г. и de и определённо что-то ещё в последней строчке, чего я не могу выделить?

Поэтому — нет, это даже и близко не решение. К слову, ничего стыдного в этом нет, это было и моим первым «быстрым решением».

Ладно, там есть паттерны, давайте используем паттерны!

Да, IntlDateFormatter в действительности внутри работает только с паттернами (константы форматов просто преобразуются в соответствующий паттерн), и при создании вы можете указать свой собственный.

Паттерн состоит из нескольких предопределённых последовательностей букв.

Посмотрим… Похоже, нам нужен паттерн "d MMMM".

<?php

foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $formatter = new IntlDateFormatter(
        $locale, 
        IntlDateFormatter::NONE, 
        IntlDateFormatter::NONE, 
        'Europe/Moscow',
        null,
        "d MMMM"
    );
    echo $formatter->format(1455111783), PHP_EOL;
}

Результат:

10 February
10 февраля
10 febrero
۱۰ فوریهٔ

Выглядит отлично! Хотя постой, это ещё что за? Ох...

Напоминаю, мы хотим

February 10
10 февраля
10 de febrero
۱۰ ﻑﻭﺭیﻩٔ ﻡ. 

Нет, ни фига не отлично, лишь одно совпадение. Всё потому, что паттерном вы указали не только части даты (день и месяц), но ещё и порядок, в котором они должны идти, и разделители. Как если бы вы сказали «сначала должен идти номер дня, затем полное название месяца, а между ними пробел». Для любой локали. Это полная ерунда.

Правда в том, что локаль это не только язык, это ещё и паттерн форматирования даты. И паттерн это не только что включить в результат, но также в каком порядке это расставить.

<?php

foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $formatter = new IntlDateFormatter(
        $locale, 
        IntlDateFormatter::LONG, 
        IntlDateFormatter::NONE, 
        'Europe/Moscow'
    );
    echo $formatter->getPattern(), PHP_EOL;
}

Результат:

MMMM d, y
d MMMM y 'г'.
d 'de' MMMM 'de' y
d MMMM y G

Смотрите, они все разные.

Но должно же быть готовое решение! Не может же быть, что его нет! Или...?

вздох Да, я думал точно так же. Как это вообще возможно, что в PHP, во взрослом языке с развитой экосистемой и одним из мощнейших коммьюнити, может не быть какого-то базового функционала? Sad but true: возможно.

В intl отсутствует как минимум одна очень важная штука — DateTimePatternGenerator класс из ICU. Он сделан ровно для того, чтобы решать нашу маленькую задачку и все прочие подобные.

Стой-стой-стой, что ещё за ICU?

ICU это "International Components for Unicode" — компоненты интернационализации для юникода.

Цитаты с сайта ICU.

ICU is a mature, widely used set of C/C++ and Java libraries providing Unicode and Globalization support for software applications. ICU is widely portable and gives applications the same results on all platforms and between C/C++ and Java software.

...

Formatting: Format numbers, dates, times and currency amounts according the conventions of a chosen locale. This includes translating month and day names into the selected language, choosing appropriate abbreviations, ordering fields correctly, etc. This data also comes from the Common Locale Data Repository.

Короче, это такой крутой набор библиотек. Расширение intl само по себе никакой магии не умеет, это что-то типа прокси к этим библиотекам.

$ php -i | grep intl -A5
intl

Internationalization support => enabled
version => 1.1.0
ICU version => 56.1
ICU Data version => 56.1

Чтобы использовать IntlDateFormatter, в вашей системе должен быть установлен ICU (или же нужно изначально собрать PHP с ICU). Разные версии ICU будут давать разные результаты форматирования.

$ dpkg -S icu
libicu52:amd64: /usr/lib/x86_64-linux-gnu/libicule.so.52.1
libicu52:amd64: /usr/lib/x86_64-linux-gnu/libicule.so.52
libicu52:amd64: /usr/lib/x86_64-linux-gnu/libicutest.so.52
...

(В системе установлена версия 52.1, а PHP, как можно видеть выше, собран с 56.1. Это нормально.)

Ясно. Ты упомянул какой-то DateTimePatternGenerator, рассказывай

Точно, DateTimePatternGenerator, это, на мой взгляд, наиболее магическая штука в ICU из тех, что про форматирование даты-времени.

Ещё одна цитата с сайта ICU:

This class provides flexible generation of date format patterns, like "yy-MM-dd".

The user can build up the generator by adding successive patterns. Once that is done, a query can be made using a "skeleton", which is a pattern which just includes the desired fields and lengths. The generator will return the "best fit" pattern corresponding to that skeleton.

The main method people will use is getBestPattern(String skeleton), since normally this class is pre-built with data from a particular locale. However, generators can be built directly from other data as well.

Это прям то, что нам надо! Мы скармливаем так называемый "skeleton" (части даты, которые нужно включить в результат форматирования) методу getBestPattern, и он возвращает наиболее подходящий паттерн, а с ним мы уже знаем, что делать: передаём в IntlDateFormatter — и готово!

Как это могло бы работать.

$skeleton = "MMMMd";
foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $pgen = new IntlDateTimePatternGenerator($locale);
    $pattern = $pgen->getBestPattern($skeleton);

    $formatter = new IntlDateFormatter(
        $locale, 
        IntlDateFormatter::NONE, 
        IntlDateFormatter::NONE, 
        'Europe/Moscow',
        null,
        $pattern
    );
    echo $formatter->format(1455111783), PHP_EOL;
}

Результат (вероятно был бы таким):

February 10
10 февраля
10 de febrero
۱۰ ﻑﻭﺭیﻩٔ ﻡ. 

Йуххху! Дадада, я тупо скопировал блок «желаемый результат». На самом деле я не знаю, что там будет, но я надеюсь, что будет так.

Так что делать, если я хочу прямо сейчас использовать кастомный формат?

В итоге я пришёл ко второму очевидному решению: нагенерить конфиг, содержащий каждый кастомный паттерн для каждой локали, которую поддерживает ваш проект. И делать это при появлении каждой новой локали.

Вот простой сниппет.

<?php

// ...
foreach ($locales as $locale) {
    $pattern = <<<CONFIG
        '%s' => [
            'medium_no_year' => "%s", // %s
            'long_no_year' => "%s", // %s
        ],

CONFIG;

    $mediumF = new IntlDateFormatter($locale, IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
    $longF = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::NONE);

    printf(
        $pattern,
        $locale,
        $mediumF->getPattern(),
        $mediumF->format(1455111783),
        $longF->getPattern(),
        $longF->format(1455111783)
    );
}

В итоге у вас будет что-то типа

        'en_US' => [
            'medium_no_year' => "MMM d, y", // Feb 10, 2016
            'long_no_year' => "MMMM d, y", // February 10, 2016
        ],
        'ru_RU' => [
            'medium_no_year' => "d MMM y 'г'.", // 10 февр. 2016 г.
            'long_no_year' => "d MMMM y 'г'.", // 10 февраля 2016 г.
        ],
        'es_ES' => [
            'medium_no_year' => "d MMM y", // 10 feb. 2016
            'long_no_year' => "d 'de' MMMM 'de' y", // 10 de febrero de 2016
        ],
        'fa_IR' => [
            'medium_no_year' => "d MMM y G", // ۱۰ فوریهٔ ۲۰۱۶ م.
            'long_no_year' => "d MMMM y G", // ۱۰ فوریهٔ ۲۰۱۶ م.
        ],

Затем нужно вручную пройтись по всем этим строкам и удалить из них часть, отвечающую за отображение года.

        'en_US' => [
            'medium_no_year' => "MMM d", // Feb 10
            'long_no_year' => "MMMM d", // February 10
        ],
        'ru_RU' => [
            'medium_no_year' => "d MMM", // 10 февр.
            'long_no_year' => "d MMMM", // 10 февраля
        ],
        'es_ES' => [
            'medium_no_year' => "d MMM", // 10 feb.
            'long_no_year' => "d 'de' MMMM", // 10 de febrero
        ],
        'fa_IR' => [
            'medium_no_year' => "d MMM", // ۱۰ فوریهٔ م.
            'long_no_year' => "d MMMM", // ۱۰ فوریهٔ م.
        ],

Когда у вас десятки локалей, это становится изнурительной работой, ПРОСТО ПОВЕРЬТЕ МНЕ (слышится тихий плач).

И это ещё не всё. Если вам захочется выводить такую дату со временем, вам придётся нагенерить в два раза больше паттернов. Потому что нельзя просто взять и соединить отформатированные отдельно дату и время. Добавить время в конец даты, в начало или куда-то в середину?

<?php

foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $pattern = <<<CONFIG
        '%s' => [
            'medium_no_year-short' => "%s", // %s
            'long_no_year-short' => "%s", // %s
        ],

CONFIG;

    $mediumF = new IntlDateFormatter($locale, IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT);
    $longF = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT);

    printf(
        $pattern,
        $locale,
        $mediumF->getPattern(),
        $mediumF->format(1455111783),
        $longF->getPattern(),
        $longF->format(1455111783)
    );
}

Результат:

        'en_US' => [
            'medium_no_year-short' => "MMM d, y, h:mm a", // Feb 10, 2016, 2:43 PM
            'long_no_year-short' => "MMMM d, y 'at' h:mm a", // February 10, 2016 at 2:43 PM
        ],
        'ru_RU' => [
            'medium_no_year-short' => "d MMM y 'г'., H:mm", // 10 февр. 2016 г., 14:43
            'long_no_year-short' => "d MMMM y 'г'., H:mm", // 10 февраля 2016 г., 14:43
        ],
        'es_ES' => [
            'medium_no_year-short' => "d MMM y H:mm", // 10 feb. 2016 14:43
            'long_no_year-short' => "d 'de' MMMM 'de' y, H:mm", // 10 de febrero de 2016, 14:43
        ],
        'fa_IR' => [
            'medium_no_year-short' => "d MMM y G،‏ H:mm", // ۱۰ فوریهٔ ۲۰۱۶ م.،‏ ۱۴:۴۳
            'long_no_year-short' => "d MMMM y G، ساعت H:mm", // ۱۰ فوریهٔ ۲۰۱۶ م.، ساعت ۱۴:۴۳
        ],

Удаляем часть, ответственную за год, опять, да.

        'en_US' => [
            'medium_no_year-short' => "MMM d, h:mm a", // Feb 10, 2:43 PM
            'long_no_year-short' => "MMMM d, 'at' h:mm a", // February 10, at 2:43 PM
        ],
        'ru_RU' => [
            'medium_no_year-short' => "d MMM, H:mm", // 10 февр., 14:43
            'long_no_year-short' => "d MMMM, H:mm", // 10 февраля, 14:43
        ],
        'es_ES' => [
            'medium_no_year-short' => "d MMM H:mm", // 10 feb. 14:43
            'long_no_year-short' => "d 'de' MMMM, H:mm", // 10 de febrero, 14:43
        ],
        'fa_IR' => [
            'medium_no_year-short' => "d MMM،‏ H:mm", // ۱۰ فوریهٔ،‏ ۱۴:۴۳
            'long_no_year-short' => "d MMMM، ساعت H:mm", // ۱۰ فوریهٔ، ساعت ۱۴:۴۳
        ],

Да вы только посмотрите на это, даже запятая в фарси это не обычная «наша» запятая, а ،, вы просто не можете угадать, как именно собрать результат из отдельных даты и времени.

Но как все остальные в мире PHP делают это?

В это сложно поверить, но они либо вообще не парятся с локализацией дат, либо делают это неправильно.

Я заглянул в исходники нескольких CMS, сделанных на PHP.

Я не стал смотреть фреймворки, ибо я считаю, что это не задача фреймворка. Хотя, может быть какая-то совсем уж базовая поддержка… Ну, может быть. Вообще, я попробовал, посмотрел на Yii2, и они просто рекомендуют использовать чистый intl. Так что давайте лучше CMS.

Drupal

С первым же поиском я наткнулся на изумительный тикет с интригующим названием — "Date intl support is broken, remove it". Лолшто!? И это не шутка, они и правда это сделали.

intl was removed

Вообще, они решают задачу форматирования дат приблизительно так же (пачка кастомных конфигов)
, но до патча (смотрите на скриншот) там был ключ intl как раз для паттерна локализации. А теперь им просто пофиг.

Ещё, если я правильно понял (я не достаточно активный пользователь Друпала), каждый пользователь после установки CMS должен сам ручками прописать все эти паттерны. Для каждой локали. Ну, или там есть что-то такое, о чём я не знаю, но выглядит это именно так.

Вот как это сделано в 8.1

date formats 8.1

А вот в версии 9.x

date formats 9.x

(Похоже, что они ещё не успели выпилить ключи intl из этой ветки)

Вообще, это не так уж плохо, но как пользователь CMS я нисколечки не хочу изучать все возможные культуры, чтобы выяснить, какие там у них принято использовать паттерны форматирования дат. Вся эта работа уже сделана в CLDR. Конечно, иногда мне нужны кастомные паттерны, но всё, на что я согласен, это указать, какие именно части даты и времени я хочу видеть в результате («только день и месяц, пожалуйста» или «будьте добры, мне бы месяц и время без секунд»).

WordPress

С WordPress у меня примерно те же отношения, я не из числа активных пользователей, поэтому я воспользовался поиском на Гитхабе. Похоже, что основная интересующая нас фунция здесь это date_i18n из functions.php (кстати, ребятушки, wtf? Файл с функциями из 5.2k строк кода? Серьёзно? У нас тут 2016-ый уже.).

wordpress date_i18n

Я честно потратил с полчаса, пытаясь понять, как оно работает. Но… но… да вы только гляньте на это.

wordpress date_i18n contents

Срань господня… Короче, оно точно не выглядит как правильно реализованная локализация дат, да хотя бы из-за date_format. Кажется, они пытаются локализовать названия месяцев и дней недели, не более. Знатоки поправят меня, если я ошибаюсь.

Joomla!

Джумлу я вообще ни разу не трогал. Поэтому схема та же.

В итоге: почти то же самое, что и в Друпале, пачка предопределённых форматов, которые нужно задать для каждой локали.

en-GB из дефолтной установки

en-GB Joomla config

Видите эти буквы? Они говорят нам о том, что Джумла использует date_format (как и многие другие) и не использует intl. Оправданно ли это? По-моему, нет. intl с версии 5.3 входит в ядро, а у нас на дворе уже PHP 7, а 5.5 это минимальнейшее требование для большей части современного кода. То есть, я абсолютно понимаю, когда фреймворки не используют какие-то сторонние расширения или фишки последних версий языка, потому что они считают, что их код должен работать хоть на утюге, и у них уже масса пользователей, у которых код работает на утюгах старых версий. Но здесь явно не тот случай. Назовём это «легаси» или «технический долг» или как-нибудь ещё и продолжим.

ModX Revolution

transport.core.system_settings.php

Долгая история про локализацию даты без года в PHP - 8

get.class.php

Долгая история про локализацию даты без года в PHP - 9

modifier.date_format.php

Долгая история про локализацию даты без года в PHP - 10

strftime звучит чуть лучше, чем date_format (там есть форматы, которые дают ложное ощущение правильной локализации) или ничего, когда мы говорим о локализации, но и эта функция не делает то, что нам нужно.

Magento2

Magento это не CMS в общепринятом смысле, это e-commerce платформа, но она широко известна, и у неё даже есть отдельный собственный фреймворк. Поэтому чего бы и нет.

И тут я должен сказать, что это единственная кодовая база в моём обзоре, в которой локализация дат сделана почти правильно! Это единственный код, в котором я встретил использование IntlDateFormatter, более того, он используется как основа компонента форматирования.

Долгая история про локализацию даты без года в PHP - 11

Но и их код не идеален. Смотрим в Timezone.php.

Долгая история про локализацию даты без года в PHP - 12

Я не смог найти места, в которых они бы пытались форматировать дату без года. Но есть ощущение, что они делают ту же ошибку, что и мы чуть ранее, пытаясь заменять год при форматировании «даты с длинным годом». Или же нет? Я не гуру регулярок (хоть и знаком с "lookahead" и "lookbehind"), поэтому я лучше просто выполню код из getDateFormatWithLongYear и посмотрю, что случится.

<?php

foreach (['en_US', 'ru_RU', 'es_ES', 'fa_IR'] as $locale) {
    $dateFormat = (new IntlDateFormatter(
        $locale,
        IntlDateFormatter::SHORT,
        IntlDateFormatter::NONE
    ))->getPattern();

    $formatWithLongYear = preg_replace(
        '/(?<!y)yy(?!y)/',
        'Y',
        $dateFormat
    );

    $formatter = new IntlDateFormatter(
        $locale, 
        IntlDateFormatter::NONE, 
        IntlDateFormatter::NONE, 
        null, 
        null, 
        $formatWithLongYear
    );
    echo $formatter->format(1455111783), PHP_EOL;
}

Результат:

2/10/2016
10.02.2016
10/2/2016
۲۰۱۶/۲/۱۰ م.

Похоже на успех! Ладно, иногда этот трюк срабатывает. Тут важнее сам факт того, что они вот так извращаются. Это ещё одно подтверждение, что в PHP не хватает того самого генератора паттернов.

А getDateTimeFormat это очевидная ошибка. Они конкатенируют паттерны даты и времени. Нет, чуваки, порядок отображения даты и времени не один и тот же во всех локалях, выше мы уже видели это.

Ещё можно заглянуть сюда. В любом случае — отличная работа, Magento!

Не хочешь ли ты сказать, что ты первый, кто заметил проблему?

Совсем нет. Ребята из HHVM давно портировали этот генератор — https://github.com/facebook/hhvm/commit/bc84daf7816e4cd268da59d535dcadfc6cf01085. Респект!
А ещё есть баг в трекере PHP — https://bugs.php.net/bug.php?id=70377 "Please add DateTimePatternGenerator to intl". Кстати, проголосуйте, если вас тоже задевает эта проблема. Вдруг там это голосование действительно что-то значит. ~Кстати, там нет защиты от CSRF~.

Критиковать любой может. Вот взял бы да сам и сделал!

Ну, вообще, после нескольких довольно жалких попыток обратить внимание сообщества на проблему и попросить кого-то из разработчиков добавить недостающие вещи в intl, я сделал — https://github.com/ksimka/intl_dtpg. Это расширение, реализующее по сути единственную функцию (в виде функции или метода класса) «найди наиболее подходящий паттерн для вот этого набора частей даты и времени». Тот самый DateTimePatternGenerator::getBestPattern (там в этом классе ещё много интересного есть, но лично мне критически не хватает пока только вот этого метода).

Но проблема в том, что я не шарю ни в C++, ни в C. Поэтому используйте его на свой страх и риск (после внедрения у нас я расскажу, всё ли там хорошо). Расширение написано на C++ на базе PHPCPP методом examples+google+stackoverflow. Поэтому любые улучшения крайне приветствуются.

А вообще, тут же у нас есть ребята из PHP core dev team, портируйте уже, плиз, эти недостающие части в intl кто-нибудь, или хотя бы задолбайте разработчиков PHP просьбами. Можно говорить, что я вас заставил, угрожая насильно обучить go.

На этом всё. Спасибо, что дочитали до конца!

Автор: MaxxArts

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js