diff --git a/Sources/Msg.php b/Sources/Msg.php
index 5dcf71f3e0..29ef811f62 100644
--- a/Sources/Msg.php
+++ b/Sources/Msg.php
@@ -758,9 +758,23 @@ function ($m) {
// Let's look at the time tags...
$message = preg_replace_callback(
- '~\[time(?:=(absolute))*\](.+?)\[/time\]~i',
- function ($m) {
- return '[time]' . (is_numeric("{$m[2]}") || @strtotime("{$m[2]}") == 0 ? "{$m[2]}" : strtotime("{$m[2]}") - ("{$m[1]}" == 'absolute' ? 0 : ((Config::$modSettings['time_offset'] + User::$me->time_offset) * 3600))) . '[/time]';
+ '~\[time(?:=([^\\]]*))?\](.+?)\[/time\]~i',
+ function ($matches) {
+ return preg_replace(
+ [
+ '~^$~',
+ '~^]*>.*$~',
+ ],
+ [
+ // If it parsed successfully, insert the resolved datetime value.
+ // This ensures that "[time]today[/time]" ends up resolving to
+ // the date the post was written, not the date it is being read.
+ '[time=$1]$2[/time]',
+ // If it didn't parse successfully, remove the BBC entirely.
+ $matches[2],
+ ],
+ Parser::transform($matches[0], Parser::INPUT_BBC),
+ );
},
$message,
);
@@ -943,10 +957,14 @@ function ($matches) {
}
// Attempt to un-parse the time to something less awful.
+ // This form will never be created by Msg::preparsecode() in SMF 3.0+
+ // but it might be present in old data.
$message = preg_replace_callback(
'~\[time\](\d{0,10})\[/time\]~i',
function ($matches) {
- return '[time]' . Time::create('@' . $matches[1])->setTimezone(new \DateTimeZone(User::getTimezone()))->format(Time::getDateFormat()) . '[/time]';
+ $time = Time::create('@' . $matches[1]);
+
+ return '[time=' . $time->format('Y-m-d\TH:i:sP') . ']' . $time->format(null, false) . '[/time]';
},
$message,
);
diff --git a/Sources/Parsers/BBCodeParser.php b/Sources/Parsers/BBCodeParser.php
index 4dc7096bd2..c43f5f6919 100644
--- a/Sources/Parsers/BBCodeParser.php
+++ b/Sources/Parsers/BBCodeParser.php
@@ -25,6 +25,7 @@
use SMF\Sapi;
use SMF\Theme;
use SMF\Time;
+use SMF\TimeInterval;
use SMF\Url;
use SMF\Utils;
@@ -718,10 +719,16 @@ class BBCodeParser extends Parser
'disabled_before' => '',
'disabled_after' => '',
],
+ [
+ 'tag' => 'time',
+ 'type' => 'unparsed_equals_content',
+ 'content' => '',
+ 'validate' => __CLASS__ . '::timeValidate',
+ ],
[
'tag' => 'time',
'type' => 'unparsed_content',
- 'content' => '$1',
+ 'content' => '',
'validate' => __CLASS__ . '::timeValidate',
],
[
@@ -2104,11 +2111,347 @@ public static function sizeValidate(array &$tag, array|string &$data, array $dis
*/
public static function timeValidate(array &$tag, array|string &$data, array $disabled, array $params): void
{
- if (is_numeric($data)) {
- $data = Time::create('@' . $data)->format();
+ // Data may be an array or a string, depending on the version of the BBC.
+ if (is_array($data)) {
+ $text = $data[0];
+ $date = $data[1];
+ } else {
+ $text = $data;
+ $date = $data;
+ }
+
+ // Get sanitized versions of the data.
+ $sanitized_text = ltrim(Time::sanitize(Time::convertToEnglish($text)), '@');
+ $sanitized_date = ltrim(Time::sanitize(Time::convertToEnglish($date)), '@');
+
+ // Special case if $date is actually a duration string.
+ if (preg_match('/^P((\d+D)(T(\d+[HMS])+)?|T(\d+[HMS])+)$/', $date)) {
+ if ($tag['type'] === 'unparsed_content') {
+ $tag['content'] = '';
+ } else {
+ $data = [$text, $date];
+ }
+
+ return;
+ }
+
+ if (preg_match('/^\d+\.?\d*\s+(year|month|week|day|hour|minute|second)s?$/', $sanitized_date)) {
+ try {
+ $duration = TimeInterval::createFromDateInterval(\DateInterval::createFromDateString($sanitized_date));
+
+ if ($tag['type'] === 'unparsed_content') {
+ $tag['content'] = '';
+ } else {
+ $data = [$text, (string) $duration];
+ }
+
+ return;
+ } catch (\Throwable $e) {
+ }
+ }
+
+ // Special handling for [time=absolute]...[/time]
+ if ($date === 'absolute') {
+ if ($sanitized_text === '') {
+ $tag['content'] = '$1';
+
+ return;
+ }
+
+ $time = Time::create((is_numeric($sanitized_text) ? '@' : '') . $sanitized_text);
+
+ $when = !empty((int) $time->format('u')) ? $time->format('Y-m-d\TH:i:s.uP') : $time->format('Y-m-d\TH:i:sP');
+
+ if (is_numeric(ltrim($text, '@'))) {
+ $text = Utils::normalizeSpaces(Utils::entityDecode($time->format(null, false)), true, true, ['collapse_hspace' => true]);
+ }
+ }
+
+ // If there is a valid date value already, use it.
+ if (!isset($when) && (is_numeric($sanitized_date) || (!empty($sanitized_date) && strtotime($sanitized_date) !== false))) {
+ $when = $sanitized_date;
+ }
+
+ // Special handling for month and day with no year.
+ if (!isset($when) && preg_match('/^(0?\d|1[0-2])-(0?\d|[12]\d|3[01])$/', $date, $matches)) {
+ $when = sprintf('%1$02d-%2$02d', $matches[1], $matches[2]);
+
+ if ($tag['type'] === 'unparsed_content') {
+ $tag['content'] = '';
+ } else {
+ $data = [$text, $when];
+ }
+
+ return;
+ }
+
+ // If we have a text value, try that.
+ if (!isset($when) && (is_numeric($sanitized_text) || (!empty($sanitized_text) && strtotime($sanitized_text) !== false))) {
+ $when = $sanitized_text;
+ }
+
+ // PHP's date parser gets confused by AM/PM combined with fractional seconds.
+ if (
+ !isset($when)
+ && preg_match('/[AaPp]\.?[Mm]\.?/', $sanitized_text, $matches)
+ && preg_match('/\d\.\d+/', $sanitized_text)
+ ) {
+ $parsed = date_parse($sanitized_text);
+
+ if (is_int($parsed['hour']) && is_int($parsed['minute'])) {
+ if (
+ $parsed['hour'] === 12
+ && str_starts_with(strtolower($matches[0]), 'a')
+ ) {
+ $sanitized_text = preg_replace(
+ '~(? 0
+ && str_starts_with(strtolower($matches[0]), 'p')
+ ) {
+ $sanitized_text = preg_replace(
+ '~(?$1';
+ // Found nothing parsable.
+ if (!isset($when)) {
+ $tag['content'] = '$1';
+
+ return;
+ }
+
+ // We have a (probably) parsable value, so try to work with it.
+ try {
+ if (is_numeric($when)) {
+ $time = Time::create('@' . $when);
+
+ // Get the formatted value for the datetime attribute.
+ $when = !empty((int) $time->format('u')) ? $time->format('Y-m-d\TH:i:s.uP') : $time->format('Y-m-d\TH:i:sP');
+
+ // Replace the raw Unix timestamp with something more pleasant.
+ if ($tag['type'] === 'unparsed_content') {
+ $text = Utils::normalizeSpaces(Utils::entityDecode($time->format(null, false)), true, true, ['collapse_hspace' => true]);
+ }
+ } else {
+ // Parse the date.
+ $parsed = date_parse($when);
+
+ // Is this time implied to be a 12 hour post-meridian time?
+ // E.g.: "today at 2:30" with no AM or PM usually means 2:30 PM
+ // in locales that use 12 hour time.
+ if (
+ // Time is between 1:00 and 7:59.
+ is_int($parsed['hour'])
+ && $parsed['hour'] > 0
+ && $parsed['hour'] < 8
+ // No explicit AM or PM was given.
+ && !preg_match('/[AaPp]\.?[Mm]\.?/', $when)
+ // The hour was given with a single digit (no preceeding zero).
+ && !preg_match('/(?format('Y');
+
+ if ($word !== 'year') {
+ $parsed['month'] = (int) $time->format('m');
+
+ if ($word !== 'month') {
+ $parsed['day'] = (int) $time->format('d');
+ }
+ }
+
+ if (
+ $parsed['hour'] === 0
+ && $parsed['minute'] === 0
+ && $parsed['second'] === 0
+ && $parsed['fraction'] === 0.0
+ ) {
+ $parsed['hour'] = false;
+ $parsed['minute'] = false;
+ $parsed['second'] = false;
+ $parsed['fraction'] = false;
+ }
+
+ if ($word !== 'year' && $word !== 'month') {
+ break;
+ }
+ }
+ }
+ }
+
+ // If any time parts are populated, populate any missing ones.
+ if (
+ !is_bool($parsed['hour'])
+ || !is_bool($parsed['minute'])
+ || !is_bool($parsed['second'])
+ || !is_bool($parsed['fraction'])
+ || $parsed['is_localtime']
+ ) {
+ $parsed['hour'] = (int) $parsed['hour'];
+ $parsed['minute'] = (int) $parsed['minute'];
+ $parsed['second'] = (int) $parsed['second'];
+
+ // If we have time parts and at least some date parts,
+ // populate all the date parts.
+ if (
+ $parsed['year'] !== false
+ || $parsed['month'] !== false
+ || $parsed['day'] !== false
+ ) {
+ $temp = $time;
+
+ if ($time < date_create('now')) {
+ if ($parsed['year'] === false) {
+ $temp->modify('+1 year');
+ } elseif ($parsed['month'] === false) {
+ $temp->modify('+1 month');
+ } elseif ($parsed['day'] === false) {
+ $temp->modify('+1 day');
+ }
+ }
+
+ $parsed['year'] = (int) ($parsed['year'] || $temp->format('Y'));
+ $parsed['month'] = (int) ($parsed['month'] || $temp->format('m'));
+ $parsed['day'] = (int) ($parsed['day'] || $temp->format('d'));
+ }
+ }
+
+ // Special case for a year and month with no day of the month.
+ if (
+ $parsed['year'] !== false
+ && $parsed['month'] !== false
+ && $parsed['day'] === 1
+ && $parsed['hour'] === false
+ && $parsed['minute'] === false
+ && $parsed['second'] === false
+ && $parsed['fraction'] === false
+ && $parsed['is_localtime'] === false
+ && !preg_match('/sun|mon|tue|wed|thu|fri|sat/i', $when)
+ ) {
+ $month_names = [
+ 1 => 'jan(uary)?',
+ 2 => 'feb(ruary)?',
+ 3 => 'mar(ch)?',
+ 4 => 'apr(il)?',
+ 5 => 'may',
+ 6 => 'jun(e)?',
+ 7 => 'jul(y)?',
+ 8 => 'aug(ust)?',
+ 9 => 'sep(t(ember)?)?',
+ 10 => 'oct(ober)?',
+ 11 => 'nov(ember)?',
+ 12 => 'dec(ember)?',
+ ];
+
+ $temp = str_replace((string) $parsed['year'], '', $when);
+
+ if (preg_match('/' . $month_names[$parsed['month']] . '/', $temp)) {
+ $temp = preg_replace('/' . $month_names[$parsed['month']] . '/', '', $temp);
+ } else {
+ $temp = preg_replace('/0?' . $parsed['month'] . '/', '', $temp, 1);
+ }
+
+ if (!str_contains((string) $parsed['day'], $when)) {
+ $parsed['day'] = false;
+ }
+ }
+
+ // Now figure out the appropriate format for the datetime attribute.
+ $key_chars = [
+ 'year' => 'Y',
+ 'month' => 'm',
+ 'day' => 'd',
+ 'hour' => 'H',
+ 'minute' => 'i',
+ 'second' => 's',
+ 'fraction' => 'u',
+ 'is_localtime' => 'P',
+ ];
+
+ foreach ($key_chars as $key => $char) {
+ $fmt[$char] = $parsed[$key] === false ? false : $char;
+ }
+
+ $fmt_string = implode('-', array_filter(
+ array_slice($fmt, 0, 3),
+ fn($arg) => $arg !== false,
+ ));
+
+ if ($fmt_string !== '' && $fmt['H'] !== false) {
+ $fmt_string .= '\\T';
+ }
+
+ $fmt_string .= implode(':', array_filter(
+ array_slice($fmt, 3, 3),
+ fn($arg) => $arg !== false,
+ ));
+
+ if ($fmt['u'] !== false && !empty((int) $time->format('u'))) {
+ $fmt_string .= (empty($fmt_string) ? '0' : '') . '.u';
+ }
+
+ if ($fmt_string !== '' && $fmt['P'] !== false) {
+ $fmt_string .= 'P';
+ }
+
+ if ($fmt_string === '') {
+ $fmt_string = 'Y-m-d\TH:i:sP';
+ }
+
+ // Get the formatted value for the datetime attribute.
+ $when = $time->format($fmt_string);
+ }
+
+ if ($tag['type'] === 'unparsed_content') {
+ $tag['content'] = '';
+ $data = $text;
+ } else {
+ $data = [$text, $when];
+ }
+ } catch (\Throwable $e) {
+ $tag['content'] = '$1';
+ }
}
/*******************