Skip to content

Commit

Permalink
Refactoring and add Russian and Ukrainian (#42)
Browse files Browse the repository at this point in the history
* add lang am and pm

* add lang am and pm

* add case for lang

* add ru lang

* add Russian

* add declension of words depending on numerals

* refactoring code add phpDoc

* multi-bytes ucfirst()

* fix

* refactoring and fix #38

* add lang ua
  • Loading branch information
Impeck authored Aug 18, 2023
1 parent 744281e commit 9c105af
Show file tree
Hide file tree
Showing 42 changed files with 1,243 additions and 181 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ The following locales are currently supported. Feel free to PR more locales if y
- `nl` — Dutch
- `pt` — Portuguese
- `ro` — Romanian
- `ru` — Russian
- `sk` — Slovak
- `ua` — Ukrainian
- `vi` — Vietnamese
- `zh` — Simplified Chinese
- `zh-TW` — Traditional Chinese
132 changes: 90 additions & 42 deletions src/CronExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Lorisleiva\CronTranslator;

/**
* Class for parsing and translating cron expressions
*/
class CronExpression
{
public string $raw;
Expand All @@ -10,29 +13,38 @@ class CronExpression
public DaysOfMonthField $day;
public MonthsField $month;
public DaysOfWeekField $weekday;
public string $locale;
public bool $timeFormat24hours;
public array $translations;


/**
* @throws CronParsingException
* @throws TranslationFileMissingException
* Constructs a new instance of the class.
*
* @param string $cron The cron expression.
* @param array $translations Optional translations for field values.
* @param bool $timeFormat24hours Whether to use 24-hour time format.
*/
public function __construct(string $cron, string $locale = 'en', bool $timeFormat24hours = false)
public function __construct(string $cron, array $translations = [], bool $timeFormat24hours = false)
{
$this->raw = $cron;
$fields = explode(' ', $cron);

$this->minute = new MinutesField($this, $fields[0]);
$this->hour = new HoursField($this, $fields[1]);
$this->day = new DaysOfMonthField($this, $fields[2]);
$this->month = new MonthsField($this, $fields[3]);
$this->weekday = new DaysOfWeekField($this, $fields[4]);
$this->locale = $locale;

$this->timeFormat24hours = $timeFormat24hours;
$this->ensureLocaleExists();
$this->loadTranslations();

$this->translations = $translations;
}

/**
* Get the cron fields
*
* @return array
*/
public function getFields(): array
{
return [
Expand All @@ -44,74 +56,110 @@ public function getFields(): array
];
}

public function langCountable(string $type, int $number): array|string
/**
* Get localized countable translation
*
* @param string $type The translation type
* @param int $number The number
* @param string $case The grammatical case
*
* @return array|string The translated string
*/
public function langCountable(string $type, int $number, string $case = 'nominative'): array|string
{
$array = $this->translations[$type];

$value = $array[$number] ?? ($array['default'] ?: '');
$value = $array[$case][$number] ?? $array[$number] ?? $array[$case]['default'] ?? $array['default'] ?? '';

return str_replace(':number', $number, $value);
}

public function lang(string $key, array $replacements = [])
/**
* Get a localized translation
*
* @param string $key The translation key
* @param array $replacements The replacements
*
* @return string The translated string
*/
public function lang(string $key, array $replacements = []): string
{
$translation = $this->getArrayDot($this->translations['fields'], $key);

foreach ($replacements as $transKey => $value) {
$translation = str_replace(':' . $transKey, $value, $translation);
}

return $translation;
return $this->pluralize($translation);
}

protected function ensureLocaleExists(string $fallbackLocale = 'en'): void
/**
* Get a nested array value using dot notation
*
* @param array $array The array
* @param string $key The key
*
* @return mixed The array value
*/
protected function getArrayDot(array $array, string $key): mixed
{
if (! is_dir($this->getTranslationDirectory())) {
$this->locale = $fallbackLocale;
$keys = explode('.', $key);

foreach ($keys as $item) {
$array = $array[$item];
}

return $array;
}

/**
* @throws TranslationFileMissingException
* Pluralize a string based on counts and forms
*
* @param string $inputString The input string
*
* @return string The pluralized string
*/
protected function loadTranslations(): void
public function pluralize(string $inputString): string
{
$this->translations = [
'days' => $this->loadTranslationFile('days'),
'fields' => $this->loadTranslationFile('fields'),
'months' => $this->loadTranslationFile('months'),
'ordinals' => $this->loadTranslationFile('ordinals'),
'times' => $this->loadTranslationFile('times'),
];
if (!preg_match_all('/(\d+)\s+{(.+?)\}/', $inputString, $matches)) {
return $inputString;
}

[$fullMatches, $counts, $forms] = $matches;

$conversionTable = [];

foreach ($counts as $key => $count) {
$conversionTable['{' . $forms[$key] . '}'] = $this->declineCount((int)$count, $forms[$key]);
}

return strtr($inputString, $conversionTable);
}

/**
* @throws TranslationFileMissingException
* Decline a count value based on forms
*
* @param int $count The count
* @param string $forms The forms
*
* @return string The declined form
*/
protected function loadTranslationFile(string $file)
protected function declineCount(int $count, string $forms): string
{
$filename = sprintf('%s/%s.php', $this->getTranslationDirectory(), $file);
$formsArray = explode('|', $forms);

if (! is_file($filename)) {
throw new TranslationFileMissingException($this->locale, $file);
if (count($formsArray) < 3) {
$formsArray[2] = $formsArray[1];
}

return include $filename;
}
$cases = [2, 0, 1, 1, 1, 2];

protected function getTranslationDirectory(): string
{
return __DIR__ . '/lang/' . $this->locale;
}

protected function getArrayDot(array $array, string $key)
{
$keys = explode('.', $key);
$count = abs((int)strip_tags($count));

foreach ($keys as $item) {
$array = $array[$item];
}
$formIndex = ($count % 100 > 4 && $count % 100 < 20)
? 2
: $cases[min($count % 10, 5)];

return $array;
return $formsArray[$formIndex];
}
}
9 changes: 9 additions & 0 deletions src/CronParsingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@

use Exception;

/**
* Exception for cron parsing errors
*/
class CronParsingException extends Exception
{

/**
* Constructor
*
* @param string $cron The invalid cron expression
*/
public function __construct(string $cron)
{
parent::__construct("Failed to parse the following CRON expression: $cron");
Expand Down
91 changes: 64 additions & 27 deletions src/CronTranslator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,74 +6,111 @@

class CronTranslator
{

/**
* Extended cron map
*
* @var array
*/
private static array $extendedMap = [
'@reboot' => '@reboot',
'@yearly' => '0 0 1 1 *',
'@annually' => '0 0 1 1 *',
'@monthly' => '0 0 1 * *',
'@weekly' => '0 0 * * 0',
'@daily' => '0 0 * * *',
'@midnight' => '0 0 * * *',
'@hourly' => '0 * * * *'
];

/**
* Translate a cron expression
*
* @param string $cron The cron expression
* @param string $locale The locale
* @param bool $timeFormat24hours Use 24 hour time
*
* @return string The translated expression
*
* @throws CronParsingException
*/
public static function translate(string $cron, string $locale = 'en', bool $timeFormat24hours = false): string
{
// Use extended map if available
if (isset(self::$extendedMap[$cron])) {
$cron = self::$extendedMap[$cron];
}

try {
$expression = new CronExpression($cron, $locale, $timeFormat24hours);
$orderedFields = static::orderFields($expression->getFields());
$translations = (new LanguageLoader($locale))->translations;

if (str_starts_with($cron, '@')) {
return self::mbUcfirst($translations["fields"]["extended"][$cron]);
}

$expression = new CronExpression($cron, $translations, $timeFormat24hours);
$fields = $expression->getFields();
$orderedFields = self::orderFields($fields);

$translations = array_map(static function (Field $field) {
return $field->translate();
}, $orderedFields);
$answer = array_map(fn (Field $field) => $field->translate(), $orderedFields);

return ucfirst(implode(' ', array_filter($translations)));
return self::mbUcfirst(implode(' ', array_filter($answer)));
} catch (Throwable $th) {
throw new CronParsingException($cron);
}
}

/**
* Order fields
*
* @param array $fields The fields
*
* @return array Ordered fields
*/
protected static function orderFields(array $fields): array
{
// Group fields by CRON types.
$onces = static::filterType($fields, 'Once');
$everys = static::filterType($fields, 'Every');
$incrementsAndMultiples = static::filterType($fields, 'Increment', 'Multiple');
// Filter by field type
$onces = self::filterByType($fields, 'Once');
$everys = self::filterByType($fields, 'Every');
$incrementsAndMultiples = self::filterByType($fields, 'Increment', 'Multiple');

// Decide whether to keep one or zero CRON type "Every".
// Only keep first every if incrementals exist
$firstEvery = reset($everys)->position ?? PHP_INT_MIN;
$firstIncrementOrMultiple = reset($incrementsAndMultiples)->position ?? PHP_INT_MAX;
$numberOfEverysKept = $firstIncrementOrMultiple < $firstEvery ? 0 : 1;

// Mark fields that will not be displayed as dropped.
// This allows other fields to check whether some
// information is missing and adapt their translation.
/** @var Field $field */
foreach (array_slice($everys, $numberOfEverysKept) as $field) {
$field->dropped = true;
}
// Mark dropped fields
array_map(fn (Field $field) => $field->dropped = true, array_slice($everys, $numberOfEverysKept));

return array_merge(
// Place one or zero "Every" field at the beginning.
array_slice($everys, 0, $numberOfEverysKept),

// Place all "Increment" and "Multiple" fields in the middle.
$incrementsAndMultiples,

// Finish with the "Once" fields reversed (i.e. from months to minutes).
array_reverse($onces)
);
}

protected static function filterType(array $fields, ...$types): array
/**
* Filter fields by type
*
* @param array $fields The fields
* @param string ...$types The types
*
* @return array The filtered fields
*/
protected static function filterByType(array $fields, string ...$types): array
{
return array_filter($fields, fn (Field $field) => $field->hasType(...$types));
}

/**
* Capitalize the first letter
*
* @param string $string
* @return string
*/
protected static function mbUcfirst(string $string): string
{
return array_filter($fields, static function (Field $field) use ($types) {
return $field->hasType(...$types);
});
$fc = mb_strtoupper(mb_substr($string, 0, 1));
return $fc . mb_substr($string, 1);
}
}
Loading

0 comments on commit 9c105af

Please sign in to comment.