Skip to content

Commit 457ab3d

Browse files
committed
Support localized frontend dates
Progress commit (some work still tbd): Backs out changes to output filters and date tv props that made them non-localized by using php datetime. Adds ability to convert strftime to Intl date.
1 parent d4bc468 commit 457ab3d

File tree

10 files changed

+611
-125
lines changed

10 files changed

+611
-125
lines changed

core/src/Revolution/Filters/modOutputFilter.php

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace MODX\Revolution\Filters;
1313

1414
use Exception;
15-
use MODX\Revolution\Formatter\modManagerDateFormatter;
15+
use MODX\Revolution\Formatter\modFrontendDateFormatter;
1616
use MODX\Revolution\modElement;
1717
use MODX\Revolution\modTag;
1818
use MODX\Revolution\modTemplateVar;
@@ -31,15 +31,14 @@ class modOutputFilter
3131
*/
3232
public $modx = null;
3333

34-
private modManagerDateFormatter $formatter;
34+
protected modFrontendDateFormatter $formatter;
3535

3636
/**
3737
* @param modX $modx A reference to the modX instance
3838
*/
3939
public function __construct(modX &$modx)
4040
{
4141
$this->modx = &$modx;
42-
$this->formatter = $this->modx->services->get(modManagerDateFormatter::class);
4342
}
4443

4544
/**
@@ -55,13 +54,24 @@ public function filter(&$element)
5554
$output = &$element->_output;
5655
$inputFilter = $element->getInputFilter();
5756
if ($inputFilter !== null && $inputFilter->hasCommands()) {
58-
$modifier_cmd = $inputFilter->getCommands();
57+
$modifier_cmd = array_map('trim', $inputFilter->getCommands());
5958
$modifier_value = $inputFilter->getModifiers();
6059
$count = count($modifier_cmd);
6160
$condition = [];
6261

62+
// Load lexicon for filters that potentially require translation
63+
if (count(array_intersect($modifier_cmd, ['date', 'idate', 'strftime', 'fuzzydate', 'ago'])) > 0) {
64+
$cultureKey = $this->modx->getOption('cultureKey', null, 'en');
65+
$locale = $this->modx->config['locale'];
66+
$lang = !empty($locale) && strlen($locale) >= 2 ? substr($locale, 0, 2) : $cultureKey ;
67+
if (empty($this->modx->lexicon)) {
68+
$this->modx->getService('lexicon', 'modLexicon');
69+
}
70+
$this->modx->lexicon->load("{$lang}:core:filters");
71+
}
72+
6373
for ($i = 0; $i < $count; $i++) {
64-
$m_cmd = trim($modifier_cmd[$i]);
74+
$m_cmd = $modifier_cmd[$i];
6575
$m_val = $modifier_value[$i];
6676

6777
$this->log('Processing Modifier: ' . $m_cmd . ' (parameters: ' . $m_val . ')');
@@ -451,19 +461,27 @@ public function filter(&$element)
451461
/* See PHP's nl2br - http://www.php.net/manual/en/function.nl2br.php */
452462
$output = nl2br($output);
453463
break;
464+
465+
case 'tabs2spaces':
466+
$spacesPerTab = !empty($m_val) ? (int)$m_val : 2 ;
467+
if (strpos($output, "\t") !== false) {
468+
$replacement = '';
469+
$i = 0;
470+
while ($i < $spacesPerTab) {
471+
$i++;
472+
$replacement .= '&nbsp;';
473+
}
474+
$output = str_replace("\t", $replacement, $output);
475+
}
476+
break;
454477

455478
case 'strftime': /** @deprecated Removal of strftime filter option tbd */
456479
case 'date':
457-
/* See PHP's datetime - https://www.php.net/manual/en/datetime.format.php */
458-
if (empty($m_val)) {
459-
$m_val = 'l, d F Y H:i:s';
460-
/* @todo this should be modx default date/time format? Lexicon? */
461-
}
462-
if (($value = filter_var($output, FILTER_VALIDATE_INT)) === false) {
463-
$value = strtotime($output);
464-
}
465-
$output = ($value !== false)
466-
? $this->formatter->format($value, $m_val)
480+
$format = !empty($m_val) ? $m_val : '%A, %d %B %Y %H:%M:%S' ;
481+
$formatter = new modFrontendDateFormatter($this->modx);
482+
$formatter->setSourceFormat($m_val);
483+
$output = ($output !== false)
484+
? $formatter->format($output, $format)
467485
: ''
468486
;
469487
break;
@@ -476,45 +494,42 @@ public function filter(&$element)
476494
$output = '';
477495
}
478496
break;
497+
479498
case 'fuzzydate':
480-
/* displays a "fuzzy" date reference */
481-
if (empty($this->modx->lexicon)) {
482-
$this->modx->getService('lexicon', 'modLexicon');
483-
}
484-
$this->modx->lexicon->load('filters');
485499
if (!empty($output)) {
486-
$defaultTimeFormat = 'h:i A';
500+
$relativeFormat = !empty($m_val) ? $m_val : '%I:%M %p' ;
487501
$time = strtotime($output);
502+
$formatter = new modFrontendDateFormatter($this->modx);
503+
$formatter->setSourceFormat($relativeFormat);
488504
if ($time >= strtotime('today')) {
489505
$output = $this->modx->lexicon(
490506
'today_at',
491-
['time' => $this->formatter->format($time, $defaultTimeFormat)]
507+
['time' => $formatter->format($time, $relativeFormat)],
508+
$lang
492509
);
493510
} elseif ($time >= strtotime('yesterday')) {
494511
$output = $this->modx->lexicon(
495512
'yesterday_at',
496-
['time' => $this->formatter->format($time, $defaultTimeFormat)]
513+
['time' => $formatter->format($time, $relativeFormat)],
514+
$lang
497515
);
498516
} else {
499517
if (empty($m_val)) {
500-
$m_val = 'M j';
518+
$m_val = '%b %e';
501519
}
502-
$output = $this->formatter->format($time, $m_val);
520+
$formatter->setSourceFormat($m_val);
521+
$output = $formatter->format($time, $m_val);
503522
}
504523
} else {
505524
$output = '&mdash;';
506525
}
507526
break;
527+
508528
case 'ago':
509529
/* calculates relative time ago from a timestamp */
510530
if (empty($output)) {
511531
break;
512532
}
513-
if (empty($this->modx->lexicon)) {
514-
$this->modx->getService('lexicon', 'modLexicon');
515-
}
516-
$this->modx->lexicon->load('filters');
517-
518533
$agoTS = [];
519534
$uts['start'] = strtotime($output);
520535
$uts['end'] = time();
@@ -569,47 +584,54 @@ public function filter(&$element)
569584
if (!empty($agoTS['years'])) {
570585
$ago[] = $this->modx->lexicon(
571586
($agoTS['years'] > 1 ? 'ago_years' : 'ago_year'),
572-
['time' => $agoTS['years']]
587+
['time' => $agoTS['years']],
588+
$lang
573589
);
574590
}
575591
if (!empty($agoTS['months'])) {
576592
$ago[] = $this->modx->lexicon(
577593
($agoTS['months'] > 1 ? 'ago_months' : 'ago_month'),
578-
['time' => $agoTS['months']]
594+
['time' => $agoTS['months']],
595+
$lang
579596
);
580597
}
581598
if (!empty($agoTS['weeks']) && empty($agoTS['years'])) {
582599
$ago[] = $this->modx->lexicon(
583600
($agoTS['weeks'] > 1 ? 'ago_weeks' : 'ago_week'),
584-
['time' => $agoTS['weeks']]
601+
['time' => $agoTS['weeks']],
602+
$lang
585603
);
586604
}
587605
if (!empty($agoTS['days']) && empty($agoTS['months']) && empty($agoTS['years'])) {
588606
$ago[] = $this->modx->lexicon(
589607
($agoTS['days'] > 1 ? 'ago_days' : 'ago_day'),
590-
['time' => $agoTS['days']]
608+
['time' => $agoTS['days']],
609+
$lang
591610
);
592611
}
593612
if (!empty($agoTS['hours']) && empty($agoTS['weeks']) && empty($agoTS['months']) && empty($agoTS['years'])) {
594613
$ago[] = $this->modx->lexicon(
595614
($agoTS['hours'] > 1 ? 'ago_hours' : 'ago_hour'),
596-
['time' => $agoTS['hours']]
615+
['time' => $agoTS['hours']],
616+
$lang
597617
);
598618
}
599619
if (!empty($agoTS['minutes']) && empty($agoTS['days']) && empty($agoTS['weeks']) && empty($agoTS['months']) && empty($agoTS['years'])) {
600620
$ago[] = $this->modx->lexicon(
601621
($agoTS['minutes'] == 1 ? 'ago_minute' : 'ago_minutes'),
602-
['time' => $agoTS['minutes']]
622+
['time' => $agoTS['minutes']],
623+
$lang
603624
);
604625
}
605626
if (empty($ago)) { /* handle <1 min */
606627
$ago[] = $this->modx->lexicon(
607628
'ago_seconds',
608-
['time' => !empty($agoTS['seconds']) ? $agoTS['seconds'] : 0]
629+
['time' => !empty($agoTS['seconds']) ? $agoTS['seconds'] : 0],
630+
$lang
609631
);
610632
}
611633
$output = implode(', ', $ago);
612-
$output = $this->modx->lexicon('ago', ['time' => $output]);
634+
$output = $this->modx->lexicon('ago', ['time' => $output], $lang);
613635
break;
614636
case 'md5':
615637
/* See PHP's md5 - http://www.php.net/manual/en/function.md5.php */
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the MODX Revolution package.
5+
*
6+
* Copyright (c) MODX, LLC
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace MODX\Revolution\Formatter;
13+
14+
use MODX\Revolution\modX;
15+
16+
class modDateFormatConverter
17+
{
18+
/**
19+
* A reference to the modX object.
20+
* @var modX $modx
21+
*/
22+
protected ?modX $modx;
23+
24+
protected string $originalFormat;
25+
26+
protected string $fromFormat;
27+
protected string $toFormat;
28+
29+
private const FORMAT_CONVERTERS_MAP = [
30+
'strftime' => [
31+
'datetime' => 'strftimeToDatetime',
32+
'intl' => 'strftimeToIntl'
33+
],
34+
'datetime' => [
35+
'intl' => 'datetimeToIntl'
36+
]
37+
];
38+
39+
private ?string $mapName;
40+
private array $map = [];
41+
42+
public function __construct(modX $modx, string $conversionRule = 'strftime->intl')
43+
{
44+
$this->modx =& $modx;
45+
$conversionRule = trim($conversionRule);
46+
if (empty($conversionRule)) {
47+
// log warn
48+
return;
49+
}
50+
if (strpos($conversionRule, '->') === false) {
51+
// log warn
52+
return;
53+
}
54+
$rule = explode('->', $conversionRule);
55+
$this->fromFormat = $rule[0];
56+
$this->toFormat = $rule[1];
57+
}
58+
59+
public function apply(string $format): string
60+
{
61+
$format = trim($format);
62+
$this->originalFormat = $format;
63+
if (!$this->getConversionMap()) {
64+
// log err
65+
return $format;
66+
}
67+
$method = $this->getConversionMethod();
68+
if (!empty($method) && method_exists($this, $method)) {
69+
$format = $this->$method($format);
70+
$this->modx->log(modX::LOG_LEVEL_ERROR, "\rNew format = {$format}");
71+
return $format;
72+
}
73+
// log warn
74+
return $format;
75+
}
76+
77+
private function getConversionMap(): bool
78+
{
79+
$this->mapName = array_key_exists($this->fromFormat, self::FORMAT_CONVERTERS_MAP) && array_key_exists($this->toFormat, self::FORMAT_CONVERTERS_MAP[$this->fromFormat])
80+
? self::FORMAT_CONVERTERS_MAP[$this->fromFormat][$this->toFormat]
81+
: null
82+
;
83+
if (!$this->mapName) {
84+
// log err
85+
return false;
86+
}
87+
$file = $this->mapName . '.map.php';
88+
$filePath = __DIR__ . '/' . ltrim($file, '/');
89+
if (!file_exists($filePath)) {
90+
$this->modx->log(modX::LOG_LEVEL_ERROR, "\rMap file at {$filePath} not found, aborting!");
91+
return false;
92+
}
93+
// $this->modx->log(modX::LOG_LEVEL_ERROR, "\rGetting map file {$file} from {$filePath}...");
94+
$this->map = require $file;
95+
// $this->modx->log(modX::LOG_LEVEL_ERROR, "\rGot conversion map!");
96+
return true;
97+
}
98+
99+
private function getConversionMethod(): string
100+
{
101+
if (!$this->fromFormat || !$this->toFormat) {
102+
// log err
103+
return '';
104+
}
105+
return strtolower($this->fromFormat) . 'To' . ucfirst(strtolower($this->toFormat));
106+
}
107+
108+
private function strftimeToIntl(string $format): string
109+
{
110+
$this->prepareEscapedFormatting($format);
111+
if (preg_match_all('/%[\w]/', $format, $parts, PREG_PATTERN_ORDER)) {
112+
foreach ($parts[0] as $part) {
113+
$replacement = $this->map[$part];
114+
// Handle pre-defined patterns, defined by {predef:const1:const2}
115+
if (in_array($part, ['%X', '%x', '%c'])) {
116+
$replacement = '{predef:' . implode(':', $replacement) . '}';
117+
/*
118+
Intl pre-defined format equivalents can not also contain other
119+
patterns or characters. Here, if a strftime pre-defined pattern is
120+
found, all other information in the original format is discarded
121+
to ensure a valid mapping is created.
122+
*/
123+
if (strlen($format) > 2) {
124+
$msg = "[Make into lexicon] A pre-defined strftime format ({$part}) was found in the original format string to be converted ({$this->originalFormat}). Other characters and/or formats in the original format were discarded to ensure a valid mapping to Intl.";
125+
$this->modx->log(modX::LOG_LEVEL_WARN, $msg);
126+
}
127+
$format = $replacement;
128+
break;
129+
}
130+
$format = str_replace($part, $replacement, $format);
131+
}
132+
}
133+
return $format;
134+
}
135+
136+
private function strftimeToDatetime(string $format): string
137+
{
138+
$this->prepareEscapedFormatting($format);
139+
if (preg_match_all('/%[\w]/', $format, $parts, PREG_PATTERN_ORDER)) {
140+
foreach ($parts[0] as $part) {
141+
$replacement = $this->map[$part];
142+
$format = str_replace($part, $replacement, $format);
143+
}
144+
}
145+
return $format;
146+
}
147+
148+
/**
149+
* Provide basic transformation of string literals in formatting pattern
150+
*/
151+
private function prepareEscapedFormatting(string &$format): void
152+
{
153+
if (strpos($format, '%%') !== false) {
154+
preg_match_all('/%%[\w]/', $format, $escapedParts, PREG_PATTERN_ORDER);
155+
foreach ($escapedParts[0] as $escapedPart) {
156+
$replacement = $this->toFormat === 'intl'
157+
? "'{$escapedPart[0]}{$escapedPart[2]}'"
158+
: $escapedPart[0] . "\\" . $escapedPart[2]
159+
;
160+
$format = str_replace($escapedPart, $replacement, $format);
161+
}
162+
// If any '%%' sequences remain, they indicate a literal '%'
163+
if (strpos($format, '%%') !== false) {
164+
$format = str_replace('%%', '%', $format);
165+
}
166+
}
167+
}
168+
}

0 commit comments

Comments
 (0)