Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 8 Mar 2013 */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ use DateInterval; use DateTimeZone; use Exception; use UnexpectedValueException; use function count; use function defined; use function in_array; use function substr; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Date/Time Class */ class DateTime extends \DateTime { /** * Create from timestamp * * @param int $timestamp UNIX Timestamp * @param bool $bypassTimezone Ignore timezone (useful for things like rfc1123() which forces to GMT anyways) * @return DateTime */ public static function ts( int $timestamp, bool $bypassTimezone=FALSE ): DateTime { $obj = new static; $obj->setTimestamp( $timestamp ); if ( !$bypassTimezone AND Dispatcher::hasInstance() and Member::loggedIn()->timezone ) { if( in_array( Member::loggedIn()->timezone, static::getTimezoneIdentifiers() ) ) { try { $obj->setTimezone( new DateTimeZone( Member::loggedIn()->timezone ) ); } catch ( Exception $e ) { } } } return $obj; } /** * @brief Cached timezone identifiers */ protected static ?array $timeZoneIdentifiers = NULL; /** * Get the valid time zone identifiers * * @note Abstracted to implement caching * @return array */ public static function getTimezoneIdentifiers(): array { if( static::$timeZoneIdentifiers === NULL ) { static::$timeZoneIdentifiers = DateTimeZone::listIdentifiers(); } return static::$timeZoneIdentifiers; } /** * Helper method to fix Argentinian Timezones https://bugs.webkit.org/show_bug.cgi?id=218542 * * @param string|null $timezone * @return string|null */ public static function getFixedTimezone( ?string $timezone=NULL ) :? string { if ( in_array( $timezone, [ 'America/Buenos_Aires', 'America/Catamarca', 'America/Cordoba', 'America/Jujuy', 'America/La_Rioja', 'America/Mendoza', 'America/Rio_Gallegos', 'America/Salta', 'America/San_Juan', 'America/San_Luis', 'America/Tucuman', 'America/Ushuaia' ] ) ) { return 'America/Argentina/' . substr( $timezone, 8 ); } /* Chromium reports the wrong timezones for these locations - https://bugs.chromium.org/p/chromium/issues/detail?id=580195 */ return match ( $timezone ) { 'Asia/Calcutta' => 'Asia/Kolkata', 'Asia/Katmandu' => 'Asia/Kathmandu', default => $timezone, }; } /** * Create New * * @return DateTime */ public static function create(): DateTime { return new static; } /** * Format a DateInterval showing only the relevant pieces. * * @param DateInterval $diff The interval * @param int $restrictParts The maximum number of "pieces" to return. Restricts "1 year, 1 month, 1 day, 1 hour, 1 minute, 1 second" to just "1 year, 1 month". Pass 0 to not reduce. * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public static function formatInterval( DateInterval $diff, int $restrictParts=2, Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); /* Figure out what pieces we have. Note that we are letting the language manager perform the formatting to implement better pluralization. */ $format = array(); if( $diff->y !== 0 ) { $format[] = $language->addToStack( 'f_years', FALSE, array( 'pluralize' => array( $diff->y ) ) ); } if( $diff->m !== 0 ) { $format[] = $language->addToStack( 'f_months', FALSE, array( 'pluralize' => array( $diff->m ) ) ); } if( $diff->d !== 0 ) { $format[] = $language->addToStack( 'f_days', FALSE, array( 'pluralize' => array( $diff->d ) ) ); } if( $diff->h !== 0 ) { $format[] = $language->addToStack( 'f_hours', FALSE, array( 'pluralize' => array( $diff->h ) ) ); } if( $diff->i !== 0 ) { $format[] = $language->addToStack( 'f_minutes', FALSE, array( 'pluralize' => array( $diff->i ) ) ); } /* If we don't have anything but seconds, return "less than a minute ago" */ if( !count($format) ) { if( $diff->s !== 0 ) { return $language->addToStack('less_than_a_minute'); } } else if( $diff->s !== 0 ) { $format[] = $language->addToStack( 'f_seconds', FALSE, array( 'pluralize' => array( $diff->s ) ) ); } /* If we are still here, reduce the number of items in the $format array as appropriate */ if( $restrictParts > 0 ) { $useOnly = array(); $haveUsed = 0; foreach( $format as $period ) { $useOnly[] = $period; $haveUsed++; if( $haveUsed >= $restrictParts ) { break; } } $format = $useOnly; } return $language->formatList( $format ); } /** * Format the date and time according to the user's locale * * @return string * @note We cast to a string explicitly because in some environments (e.g. Windows) boolean FALSE can be returned from strftime if conversion specifiers are not valid */ public function __toString() { return static::strftime( '%x ' . static::localeTimeFormat(), $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), Member::loggedIn()->language() ); } /** * Get HTML output * * @param bool $capitalize TRUE if by itself, FALSE if in the middle of a sentence * @param bool $short Whether or not to use the short form * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function html(bool $capitalize=TRUE, bool $short=FALSE, Lang|Member $memberOrLanguage=NULL ): string { $format = $short ? 1 : ( $capitalize ? static::RELATIVE_FORMAT_NORMAL : static::RELATIVE_FORMAT_LOWER ); $shortLong = $short ? 'short' : 'long'; return ""; } /** * Get total number of days from DateInterval * * @param DateInterval $interval DateInterval * @return int */ static public function intervalToDays( DateInterval $interval ): int { $days = 0; if ( $interval->y ) { $days += ( 365 * $interval->y ); } if ( $interval->m ) { $days += ( ( 365 / 12 ) * $interval->m ); } if ( $interval->d ) { $days += $interval->d; } return $days; } /** * Format the date according to the user's locale (without the time) * * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function localeDate( Lang|Member $memberOrLanguage=NULL ): string { return static::strftime( '%x', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), static::determineLanguage( $memberOrLanguage ) ); } /** * Format the date to return month and day without a year * * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function dayAndMonth( Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); return $language->addToStack( '_date_day_and_month', FALSE, array( 'pluralize' => array( static::strftime( ( strftime( '%d' ) !== FALSE ) ? '%d' : '%e', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } /** * Format the date to return short month and day without a year * * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function dayAndShortMonth( Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); return $language->addToStack( '_date_this_year_short', FALSE, array( 'pluralize' => array( static::strftime( ( strftime( '%d' ) !== FALSE ) ? '%d' : '%e', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } /** * Format the date to return short month and full year. * * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function shortMonthAndFullYear( Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); return $language->addToStack( '_date_month_year_short', FALSE, array( 'sprintf' => array( static::strftime( '%Y', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ) ), 'pluralize' => array( static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } /** * Get locale date, forced to 4-digit year format * * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function fullYearLocaleDate( Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); $timestamp = $this->getTimestamp() + $this->getTimezone()->getOffset( $this ); $dateString = static::strftime( '%x', $timestamp, $language ); $twoDigitYear = static::strftime( '%y', $timestamp, $language ); $fourDigitYear = static::strftime( '%Y', $timestamp, $language ); $dateString = preg_replace_callback( "/(\s|\/|,|-){$twoDigitYear}$/", function( $matches ) use ( $fourDigitYear ) { return $matches[1] . $fourDigitYear; }, $dateString ); return $language->convertString( $dateString ); } /** * Locale time format * * PHP always wants to use 24-hour format but some * countries prefer 12-hour format, so we override * specifically for them * * @param bool $seconds If TRUE, will include seconds * @param bool $minutes If TRUE, will include minutes * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public static function localeTimeFormat( bool $seconds=FALSE, bool $minutes=TRUE, Lang|Member $memberOrLanguage=NULL ): string { if ( in_array( preg_replace( '/\.UTF-?8$/', '', static::determineLanguage( $memberOrLanguage )->short ), array( 'sq_AL', // Albanian - Albania 'zh_SG', 'sgp', 'singapore', // Chinese - Singapore 'zh_TW', 'twn', 'taiwan', // Chinese - Taiwan 'en_AU', 'aus', 'australia', 'australian', 'ena', 'english-aus', // English - Australia 'en_CA', 'can', 'canda', 'canadian', 'enc', 'english-can', // English - Canada 'en_NZ', 'nzl', 'new zealand', 'new-zealand', 'nz', 'english-nz', 'enz', // English - New Zealand 'en_PH', // English - Phillipines 'en_ZA', // English - South Africa 'en_US', 'american', 'american english', 'american-english', 'english-american', 'english-us', 'english-usa', 'enu', 'us', 'usa', 'america', 'united states', 'united-states', // English - United States 'el_CY', // Greek - Cyprus 'el_GR', 'grc', 'greece', 'ell', 'greek', // Greek - Greece 'ms_MY', // Malay - Malaysia 'ko_KR', 'kor', 'korean', // Korean - South Korea 'es_MX', 'mex', 'mexico', 'esm', 'spanish-mexican', // Spanish - Mexico ) ) ) { if( strftime( '%I' ) !== FALSE ) { return '%I' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : ' ' ) . ' %p'; } else { return '%l' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : ' ' ) . ' %p'; } } return '%H' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : '' ); } /** * Format the time according to the user's locale (without the date) * * @param bool $seconds If TRUE, will include seconds * @param bool $minutes If TRUE, will include minutes * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function localeTime( bool $seconds=TRUE, bool $minutes=TRUE, Lang|Member $memberOrLanguage=NULL ): string { return static::strftime( static::localeTimeFormat($seconds, $minutes, $memberOrLanguage), $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), static::determineLanguage( $memberOrLanguage ) ); } /** * @brief Normal relative format: Yesterday at 2pm */ const RELATIVE_FORMAT_NORMAL = 0; /** * @brief Lowercase relative format: yesterday at 2pm */ const RELATIVE_FORMAT_LOWER = 2; /** * @brief Short relative format: 1dy (for mobile view) */ const RELATIVE_FORMAT_SHORT = 1; /** * Format the date relative to the current date/time * e.g. "30 minutes ago" * * @param int $format The format (see RELATIVE_FORMAT_* constants) * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function relative( int $format=0, Lang|Member $memberOrLanguage=NULL ): string { $language = static::determineLanguage( $memberOrLanguage ); if( Settings::i()->relative_dates_enable ) { $now = static::ts(time()); $difference = $this->diff( $now ); $capitalKey = ( $format == static::RELATIVE_FORMAT_LOWER ) ? '' : '_c'; /* More than a year ago...1y is good enough for short dates */ if( $format == static::RELATIVE_FORMAT_SHORT AND !$difference->invert and $now->format( 'Y', $language ) != $this->format( 'Y', $language ) ) { if( $difference->y ) { return $language->addToStack( 'f_years_short', FALSE, array( 'pluralize' => array( $difference->y ) ) ); } else { return $language->addToStack( '_date_this_year_short', FALSE, array( 'pluralize' => array( static::strftime( ( strftime( '%d' ) !== FALSE ) ? '%d' : '%e', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } } /* In the past and from this year */ if ( !$difference->invert and $now->format( 'Y', $language ) == $this->format( 'Y', $language ) ) { /* More than a week ago: "March 4" */ if ( $difference->m or $difference->d >= 6 ) { return $language->addToStack( $format == static::RELATIVE_FORMAT_SHORT ? '_date_this_year_short' : '_date_this_year_long', FALSE, array( 'pluralize' => array( static::strftime( ( strftime( '%d' ) !== FALSE ) ? '%d' : '%e', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } /* Less than a week but more than a day ago */ elseif ( $difference->d ) { $compare = clone $this; /* Short format: "1 dy" */ if ( $format === static::RELATIVE_FORMAT_SHORT ) { return $language->addToStack( 'f_days_short', FALSE, array( 'pluralize' => array( $difference->d ) ) ); } /* Yesterday: "Yesterday at 23:56" */ elseif ( $difference->d == 1 && ( $compare->add( new DateInterval( 'P1D' ) )->format( 'Y-m-d', $language ) == $now->format( 'Y-m-d', $language ) ) ) { return $language->addToStack( "_date_yesterday{$capitalKey}", FALSE, array( 'sprintf' => array( $this->localeTime( FALSE, TRUE, $language ) ) ) ); } /* Other: "Wednesday at 23:56" */ else { return $language->addToStack( "_date_this_week{$capitalKey}", FALSE, array( 'sprintf' => array( $this->localeTime( FALSE, TRUE, $language ) ), 'pluralize' => array( $this->strFormat( '%w', $language ), ) ) ); } } /* Less than a day but more than an hour ago */ elseif ( $difference->h ) { /* Short format: "1 hr" */ if ( $format == static::RELATIVE_FORMAT_SHORT ) { return $language->addToStack( 'f_hours_short', FALSE, array( 'pluralize' => array( $difference->h ) ) ); } /* Long format: "1 hour ago" */ else { return $language->addToStack( "_date_ago{$capitalKey}", FALSE, array( 'sprintf' => array( $language->addToStack( 'f_hours', FALSE, array( 'pluralize' => array( $difference->h ) ) ) ) ) ); } } /* Less than an hour but more than a minute ago */ elseif ( $difference->i ) { /* Short format: "4 min" */ if ( $format == static::RELATIVE_FORMAT_SHORT ) { return $language->addToStack( 'f_minutes_short', FALSE, array( 'pluralize' => array( $difference->i ) ) ); } /* Short format: "4 minutes ago" */ else { return $language->addToStack( "_date_ago{$capitalKey}", FALSE, array( 'sprintf' => array( $language->addToStack( 'f_minutes', FALSE, array( 'pluralize' => array( $difference->i ) ) ) ) ) ); } } /* Less than a minute ago */ else { if ( $format == static::RELATIVE_FORMAT_SHORT ) { return $language->addToStack( 'f_minutes_short', FALSE, array( 'pluralize' => array( 1 ) ) ); } else { return $language->addToStack( "_date_just_now{$capitalKey}" ); } } } $_format = ( $format == static::RELATIVE_FORMAT_SHORT ) ? '_date_last_year_short' : '_date_last_year_long'; } else { $_format = ( $format == static::RELATIVE_FORMAT_SHORT ) ? '_date_last_year_short' : '_date_last_year_long_full'; } /* Anything else - "March 4, 1992" */ return $language->addToStack( $_format, FALSE, array( 'sprintf' => array( static::strftime( '%Y', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), $this->localeTime( FALSE, TRUE, $language ) ), 'pluralize' => array( static::strftime( ( strftime( '%d' ) !== FALSE ) ? '%d' : '%e', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), static::strftime( '%m', $this->getTimestamp() + $this->getTimezone()->getOffset( $this ), $language ), ) ) ); } /** * Format times based on strftime() calls instead of date() calls, and convert to UTF-8 if necessary * * @param string $format Format accepted by strftime() * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function strFormat( string $format, Lang|Member $memberOrLanguage=NULL ): string { /* We only do this on Windows - Windows does not support the %e formatter */ if( mb_strtoupper( mb_substr( PHP_OS, 0, 3 ) ) === 'WIN' ) { $format = preg_replace( '#(?getTimestamp() + $this->getTimezone()->getOffset( $this ), static::determineLanguage( $memberOrLanguage ) ); } /** * Wrapper for format() so we can convert to UTF-8 if needed * * @param string $format Format accepted by date() * @param Lang|Member|null $memberOrLanguage The language or member to use, or NULL for currently logged in member * @return string */ public function format( string $format, Lang|Member $memberOrLanguage=NULL ): string { /* If this is just the year, which we do periodically, then we can skip UTF-8 conversion stuff since the result is always an integer - saves resources, especially from the relative() method which can be called 25+ times per page load and can call this method 4 times each*/ if( $format == 'Y' ) { return parent::format( $format ); } return static::determineLanguage( $memberOrLanguage )->convertString( parent::format( $format ) ); } /** * Format the date for the datetime attribute HTML `