diff --git a/app/Liquid/Filters/Date.php b/app/Liquid/Filters/Date.php index 2f730ac..6bc81fc 100644 --- a/app/Liquid/Filters/Date.php +++ b/app/Liquid/Filters/Date.php @@ -2,6 +2,7 @@ namespace App\Liquid\Filters; +use App\Liquid\Utils\ExpressionUtils; use Carbon\Carbon; use Keepsuit\Liquid\Filters\FiltersProvider; @@ -22,4 +23,33 @@ class Date extends FiltersProvider return Carbon::now()->subDays($days)->toDateString(); } + + /** + * Format a date string with ordinal day (1st, 2nd, 3rd, etc.) + * + * @param string $dateStr The date string to parse + * @param string $strftimeExp The strftime format string with <> placeholder + * @return string The formatted date with ordinal day + */ + public function ordinalize(string $dateStr, string $strftimeExp): string + { + $date = Carbon::parse($dateStr); + $ordinalDay = $date->ordinal('day'); + + // Convert strftime format to PHP date format + $phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp); + + // Split the format string by the ordinal day placeholder + $parts = explode('<>', $phpFormat); + + if (count($parts) === 2) { + $before = $date->format($parts[0]); + $after = $date->format($parts[1]); + + return $before.$ordinalDay.$after; + } + + // Fallback: if no placeholder found, just format normally + return $date->format($phpFormat); + } } diff --git a/app/Liquid/Utils/ExpressionUtils.php b/app/Liquid/Utils/ExpressionUtils.php index 9ed70d2..8a5bdb0 100644 --- a/app/Liquid/Utils/ExpressionUtils.php +++ b/app/Liquid/Utils/ExpressionUtils.php @@ -84,6 +84,7 @@ class ExpressionUtils if (self::evaluateCondition($condition['left'], $variable, $object)) { return true; } + return self::evaluateCondition($condition['right'], $variable, $object); case 'comparison': @@ -158,4 +159,52 @@ class ExpressionUtils return $expression; } + + /** + * Convert strftime format string to PHP date format string + * + * @param string $strftimeFormat The strftime format string + * @return string The PHP date format string + */ + public static function strftimeToPhpFormat(string $strftimeFormat): string + { + $conversions = [ + // Special Ruby format cases + '%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP) + '%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP) + '%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP) + '%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP) + '%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP) + '%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP) + '%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP) + '%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP) + '%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP) + '%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP) + + // Standard strftime conversions + '%A' => 'l', // Full weekday name + '%a' => 'D', // Abbreviated weekday name + '%B' => 'F', // Full month name + '%b' => 'M', // Abbreviated month name + '%Y' => 'Y', // Full year (4 digits) + '%y' => 'y', // Year without century (2 digits) + '%m' => 'm', // Month as decimal number (01-12) + '%d' => 'd', // Day of month as decimal number (01-31) + '%H' => 'H', // Hour in 24-hour format (00-23) + '%I' => 'h', // Hour in 12-hour format (01-12) + '%M' => 'i', // Minute as decimal number (00-59) + '%S' => 's', // Second as decimal number (00-59) + '%p' => 'A', // AM/PM + '%P' => 'a', // am/pm + '%j' => 'z', // Day of year as decimal number (001-366) + '%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0) + '%U' => 'W', // Week number of year (00-53, Sunday is first day) + '%W' => 'W', // Week number of year (00-53, Monday is first day) + '%c' => 'D M j H:i:s Y', // Date and time representation + '%x' => 'm/d/Y', // Date representation + '%X' => 'H:i:s', // Time representation + ]; + + return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat); + } } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 2fd3718..b372cdd 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -216,15 +216,15 @@ class Plugin extends Model */ private function applyLiquidReplacements(string $template): string { - $replacements = [ - 'date: "%N"' => 'date: "u"', - 'date: "%u"' => 'date: "u"', - '%-m/%-d/%Y' => 'm/d/Y', - ]; + + $replacements = []; // Apply basic replacements $template = str_replace(array_keys($replacements), array_values($replacements), $template); + // Convert Ruby/strftime date formats to PHP date formats + $template = $this->convertDateFormats($template); + // Convert {% render "template" with %} syntax to {% render "template", %} syntax $template = preg_replace( '/{%\s*render\s+([^}]+?)\s+with\s+/i', @@ -237,7 +237,7 @@ class Plugin extends Model // Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %} $template = preg_replace_callback( '/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/', - function ($matches): string { + function (array $matches): string { $variableName = mb_trim($matches[1]); $collection = mb_trim($matches[2]); $filter = mb_trim($matches[3]); @@ -251,6 +251,40 @@ class Plugin extends Model return $template; } + /** + * Convert Ruby/strftime date formats to PHP date formats in Liquid templates + */ + private function convertDateFormats(string $template): string + { + // Handle date filter formats: date: "format" or date: 'format' + $template = preg_replace_callback( + '/date:\s*(["\'])([^"\']+)\1/', + function (array $matches): string { + $quote = $matches[1]; + $format = $matches[2]; + $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); + + return 'date: '.$quote.$convertedFormat.$quote; + }, + $template + ); + + // Handle l_date filter formats: l_date: "format" or l_date: 'format' + $template = preg_replace_callback( + '/l_date:\s*(["\'])([^"\']+)\1/', + function (array $matches): string { + $quote = $matches[1]; + $format = $matches[2]; + $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); + + return 'l_date: '.$quote.$convertedFormat.$quote; + }, + (string) $template + ); + + return $template; + } + /** * Resolve Liquid variables in a template string using the Liquid template engine * @@ -313,6 +347,9 @@ class Plugin extends Model 'config' => $this->configuration ?? [], ...(is_array($this->data_payload) ? $this->data_payload : []), 'trmnl' => [ + 'system' => [ + 'timestamp_utc' => now()->utc()->timestamp, + ], 'user' => [ 'utc_offset' => '0', 'name' => $this->user->name ?? 'Unknown User', diff --git a/app/Notifications/BatteryLow.php b/app/Notifications/BatteryLow.php index e590398..17fb1da 100644 --- a/app/Notifications/BatteryLow.php +++ b/app/Notifications/BatteryLow.php @@ -36,7 +36,7 @@ class BatteryLow extends Notification return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]); } - public function toWebhook(object $notifiable): \App\Notifications\Messages\WebhookMessage + public function toWebhook(object $notifiable): WebhookMessage { return WebhookMessage::create() ->data([ diff --git a/tests/Unit/Liquid/Filters/DateTest.php b/tests/Unit/Liquid/Filters/DateTest.php index d967951..7de8949 100644 --- a/tests/Unit/Liquid/Filters/DateTest.php +++ b/tests/Unit/Liquid/Filters/DateTest.php @@ -30,3 +30,65 @@ test('days_ago filter with large number works correctly', function (): void { expect($filter->days_ago(100))->toBe($hundredDaysAgo); }); + +test('ordinalize filter formats date with ordinal day', function (): void { + $filter = new Date(); + + expect($filter->ordinalize('2025-10-02', '%A, %B <>, %Y')) + ->toBe('Thursday, October 2nd, 2025'); +}); + +test('ordinalize filter handles datetime string with timezone', function (): void { + $filter = new Date(); + + expect($filter->ordinalize('2025-12-31 16:50:38 -0400', '%A, %b <>')) + ->toBe('Wednesday, Dec 31st'); +}); + +test('ordinalize filter handles different ordinal suffixes', function (): void { + $filter = new Date(); + + // 1st + expect($filter->ordinalize('2025-01-01', '<>')) + ->toBe('1st'); + + // 2nd + expect($filter->ordinalize('2025-01-02', '<>')) + ->toBe('2nd'); + + // 3rd + expect($filter->ordinalize('2025-01-03', '<>')) + ->toBe('3rd'); + + // 4th + expect($filter->ordinalize('2025-01-04', '<>')) + ->toBe('4th'); + + // 11th (special case) + expect($filter->ordinalize('2025-01-11', '<>')) + ->toBe('11th'); + + // 12th (special case) + expect($filter->ordinalize('2025-01-12', '<>')) + ->toBe('12th'); + + // 13th (special case) + expect($filter->ordinalize('2025-01-13', '<>')) + ->toBe('13th'); + + // 21st + expect($filter->ordinalize('2025-01-21', '<>')) + ->toBe('21st'); + + // 22nd + expect($filter->ordinalize('2025-01-22', '<>')) + ->toBe('22nd'); + + // 23rd + expect($filter->ordinalize('2025-01-23', '<>')) + ->toBe('23rd'); + + // 24th + expect($filter->ordinalize('2025-01-24', '<>')) + ->toBe('24th'); +});