Created
October 17, 2025 20:03
-
-
Save developerfromjokela/33582a9f90e6bfa032808759e3451488 to your computer and use it in GitHub Desktop.
Calendar app for OpenCARWINGS via datachannel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| // Handle POST request with timezone data | |
| $postData = null; | |
| $timezoneOffset = 3; // Default EEST offset in hours | |
| $userTimezone = 'Europe/Helsinki'; // Default timezone | |
| if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| $postJson = file_get_contents('php://input'); | |
| $postData = json_decode($postJson, true); | |
| if ($postData && isset($postData['tz'])) { | |
| $timezoneOffset = floatval($postData['tz']); | |
| $timezoneMap = [ | |
| -12 => 'Pacific/Kwajalein', -11 => 'Pacific/Midway', -10 => 'Pacific/Honolulu', | |
| -9 => 'America/Anchorage', -8 => 'America/Los_Angeles', -7 => 'America/Denver', | |
| -6 => 'America/Chicago', -5 => 'America/New_York', -4 => 'America/Halifax', | |
| -3 => 'America/Sao_Paulo', -2 => 'Atlantic/South_Georgia', -1 => 'Atlantic/Azores', | |
| 0 => 'Europe/London', 1 => 'Europe/Berlin', 2 => 'Europe/Athens', | |
| 3 => 'Europe/Helsinki', 4 => 'Asia/Dubai', 5 => 'Asia/Karachi', | |
| 6 => 'Asia/Dhaka', 7 => 'Asia/Bangkok', 8 => 'Asia/Shanghai', | |
| 9 => 'Asia/Tokyo', 10 => 'Australia/Sydney', 11 => 'Pacific/Norfolk', | |
| 12 => 'Pacific/Auckland' | |
| ]; | |
| $userTimezone = $timezoneMap[intval($timezoneOffset)] ?? 'Europe/Helsinki'; | |
| } | |
| } | |
| // Set timezone | |
| date_default_timezone_set($userTimezone); | |
| // Approximate sunrise/sunset | |
| function approximateSunriseSunset($timezoneOffset) { | |
| $currentTime = time(); | |
| $dayOfYear = intval(date('z', $currentTime)); | |
| $baseSunrise = 6.0; | |
| $baseSunset = 18.0; | |
| $seasonalVariation = 2.5 * sin(2 * pi() * ($dayOfYear - 80) / 365); | |
| $latitudeEffect = ($timezoneOffset > -6 && $timezoneOffset < 12) ? abs($timezoneOffset) * 0.1 : 0; | |
| $sunrise = max(4.0, min(8.0, $baseSunrise - $seasonalVariation - $latitudeEffect)); | |
| $sunset = max(16.0, min(20.0, $baseSunset + $seasonalVariation + $latitudeEffect)); | |
| return [$sunrise, $sunset]; | |
| } | |
| // Get sunrise/sunset times | |
| list($sunriseHour, $sunsetHour) = approximateSunriseSunset($timezoneOffset); | |
| $currentHour = intval(date('H')) + (intval(date('i')) / 60.0); | |
| $isDarkMode = ($currentHour < $sunriseHour || $currentHour > $sunsetHour); | |
| function fetchICS($url) { | |
| $ch = curl_init(); | |
| curl_setopt($ch, CURLOPT_URL, $url); | |
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
| curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); | |
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); | |
| $icsString = curl_exec($ch); | |
| $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| curl_close($ch); | |
| return ($icsString !== false && $httpCode === 200) ? $icsString : false; | |
| } | |
| function parseICS($icsContent) { | |
| $events = []; | |
| $lines = explode("\n", $icsContent); | |
| $event = null; | |
| foreach ($lines as $line) { | |
| $line = trim($line); | |
| if (empty($line)) continue; | |
| if ($line === 'BEGIN:VEVENT') { | |
| $event = []; | |
| continue; | |
| } | |
| if ($line === 'END:VEVENT') { | |
| if ($event) $events[] = $event; | |
| $event = null; | |
| continue; | |
| } | |
| if ($event === null) continue; | |
| $parts = explode(':', $line, 2); | |
| if (count($parts) < 2) continue; // Skip invalid lines | |
| $keyPart = $parts[0]; | |
| $value = $parts[1]; | |
| $params = []; | |
| if (strpos($keyPart, ';') !== false) { | |
| $keyParts = explode(';', $keyPart); | |
| $key = array_shift($keyParts); | |
| foreach ($keyParts as $p) { | |
| if (strpos($p, '=') === false) continue; | |
| list($pkey, $pval) = explode('=', $p, 2); | |
| $params[$pkey] = $pval; | |
| } | |
| } else { | |
| $key = $keyPart; | |
| } | |
| if ($key === 'DTSTART' || $key === 'DTEND') { | |
| $tzid = isset($params['TZID']) ? $params['TZID'] : 'UTC'; | |
| $isUTC = substr($value, -1) === 'Z'; | |
| if ($isUTC) { | |
| $value = substr($value, 0, -1); | |
| $tzid = 'UTC'; | |
| } | |
| $isAllDay = (strlen($value) === 8); | |
| $format = $isAllDay ? 'Ymd' : 'Ymd\THis'; | |
| $tz = new DateTimeZone($tzid); | |
| $date = DateTime::createFromFormat($format, $value, $tz); | |
| if ($date) { | |
| $date->setTimezone(new DateTimeZone(date_default_timezone_get())); | |
| $event[$key] = $date; | |
| $event[$key . '_ALLDAY'] = $isAllDay; | |
| } | |
| } else { | |
| $event[$key] = $value; | |
| } | |
| } | |
| return $events; | |
| } | |
| function reverseGeocode($address) { | |
| $url = 'https://nominatim.openstreetmap.org/search?format=json&q=' . urlencode($address) . '&limit=1'; | |
| $ch = curl_init(); | |
| curl_setopt($ch, CURLOPT_URL, $url); | |
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
| curl_setopt($ch, CURLOPT_USERAGENT, 'CalendarApp/1.0'); | |
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); | |
| $response = curl_exec($ch); | |
| curl_close($ch); | |
| if ($response) { | |
| $data = json_decode($response, true); | |
| if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) { | |
| return ['lat' => floatval($data[0]['lat']), 'lon' => floatval($data[0]['lon'])]; | |
| } | |
| } | |
| return null; | |
| } | |
| function extractLocationData($event) { | |
| $phone_number = ''; | |
| $map_point = null; | |
| // Fields to check for phone numbers (add more if needed) | |
| $fieldsToCheck = ['DESCRIPTION', 'CONTACT', 'ORGANIZER']; | |
| foreach ($fieldsToCheck as $field) { | |
| if (isset($event[$field]) && !empty($event[$field])) { | |
| $content = $event[$field]; | |
| // Handle tel: URI format in CONTACT (e.g., CONTACT:tel:+3581234567) | |
| if ($field === 'CONTACT' && stripos($content, 'tel:') === 0) { | |
| $phone_number = substr($content, 4); // Strip 'tel:' prefix | |
| break; // Use this if found | |
| } | |
| // Apply regex to the content | |
| if (preg_match('/\+?\d{1,3}[-.\s]?\d{3}[-.\s]?\d{3,4}[-.\s]?\d{0,4}/', $content, $matches)) { | |
| $phone_number = $matches[0]; | |
| break; // Use the first match found | |
| } | |
| } | |
| } | |
| if (isset($event['GEO'])) { | |
| $geo = explode(';', $event['GEO']); | |
| if (count($geo) === 2 && is_numeric($geo[0]) && is_numeric($geo[1])) { | |
| $map_point = ['lat' => floatval($geo[0]), 'lon' => floatval($geo[1])]; | |
| } | |
| } elseif (isset($event['LOCATION']) && !empty($event['LOCATION'])) { | |
| $map_point = reverseGeocode($event['LOCATION']); | |
| } | |
| return ['phone_number' => $phone_number, 'map_point' => $map_point]; | |
| } | |
| function getUpcomingEvents($events) { | |
| $currentTime = new DateTime(); | |
| $todayStr = $currentTime->format('Y-m-d'); | |
| $upcomingEvents = []; | |
| $todayEvents = []; | |
| foreach ($events as $event) { | |
| if (!isset($event['DTSTART'])) continue; | |
| $startDateStr = $event['DTSTART']->format('Y-m-d'); | |
| $startTime = $event['DTSTART']; | |
| if ($startDateStr === $todayStr && $startTime >= $currentTime) { | |
| $todayEvents[] = $event; | |
| } elseif ($startTime >= $currentTime) { | |
| $upcomingEvents[] = $event; | |
| } | |
| } | |
| usort($upcomingEvents, function($a, $b) { | |
| return $a['DTSTART'] <=> $b['DTSTART']; | |
| }); | |
| usort($todayEvents, function($a, $b) { | |
| return $a['DTSTART'] <=> $b['DTSTART']; | |
| }); | |
| return ['next' => $upcomingEvents ? $upcomingEvents[0] : null, 'today' => $todayEvents]; | |
| } | |
| function generateOnScreenText($eventOrEvents, $isSingleEvent = false) { | |
| if (!$eventOrEvents) return 'No upcoming events.'; | |
| $todayStr = (new DateTime())->format('Y-m-d'); | |
| if ($isSingleEvent) { | |
| $event = $eventOrEvents; | |
| $summary = isset($event['SUMMARY']) ? $event['SUMMARY'] : 'No title'; | |
| $location = isset($event['LOCATION']) ? "at {$event['LOCATION']}" : ''; | |
| $eventDateStr = $event['DTSTART']->format('Y-m-d'); | |
| $datePrefix = ($eventDateStr !== $todayStr) ? $event['DTSTART']->format('M d, Y') . ': ' : ''; | |
| if (isset($event['DTSTART_ALLDAY']) && $event['DTSTART_ALLDAY']) { | |
| return "Next Event:\n{$datePrefix}All day: $summary\n$location"; | |
| } else { | |
| $start = $event['DTSTART']->format('H:i'); | |
| $end = isset($event['DTEND']) ? $event['DTEND']->format('H:i') : ''; | |
| $timeStr = $end ? "$start - $end" : $start; | |
| return "Next Event:\n{$datePrefix}$timeStr: $summary\n$location"; | |
| } | |
| } else { | |
| $events = $eventOrEvents; | |
| $text = "TODAY'S UPCOMING EVENTS:\n"; | |
| if (empty($events)) return $text . "None"; | |
| foreach ($events as $event) { | |
| $summary = isset($event['SUMMARY']) ? $event['SUMMARY'] : 'No title'; | |
| if (isset($event['DTSTART_ALLDAY']) && $event['DTSTART_ALLDAY']) { | |
| $text .= "All day: $summary\n"; | |
| } else { | |
| $start = $event['DTSTART']->format('H:i'); | |
| $end = isset($event['DTEND']) ? $event['DTEND']->format('H:i') : ''; | |
| $timeStr = $end ? "$start - $end" : $start; | |
| $text .= "$timeStr: $summary\n"; | |
| } | |
| } | |
| return $text; | |
| } | |
| } | |
| function generateTTSText($eventOrEvents, $isSingleEvent = false) { | |
| if (!$eventOrEvents) return 'There are no upcoming events.'; | |
| $todayStr = (new DateTime())->format('Y-m-d'); | |
| if ($isSingleEvent) { | |
| $event = $eventOrEvents; | |
| $summary = isset($event['SUMMARY']) ? $event['SUMMARY'] : 'no title'; | |
| $location = isset($event['LOCATION']) ? " at {$event['LOCATION']}" : ''; | |
| $eventDateStr = $event['DTSTART']->format('Y-m-d'); | |
| $datePrefix = ($eventDateStr !== $todayStr) ? $event['DTSTART']->format('F jS, Y') . ", " : ''; | |
| if (isset($event['DTSTART_ALLDAY']) && $event['DTSTART_ALLDAY']) { | |
| return "The next event is on {$datePrefix}all day, $summary$location."; | |
| } else { | |
| $startHour = intval($event['DTSTART']->format('H')); | |
| $startTTS = hourToWords($startHour); | |
| $endTTS = ''; | |
| if (isset($event['DTEND'])) { | |
| $endHour = intval($event['DTEND']->format('H')); | |
| $endTTS = ' to ' . hourToWords($endHour); | |
| } | |
| return "The next event is on {$datePrefix}from $startTTS$endTTS, $summary$location."; | |
| } | |
| } else { | |
| $events = $eventOrEvents; | |
| if (empty($events)) return "There are no upcoming events today."; | |
| $rangeTexts = []; | |
| foreach ($events as $event) { | |
| $summary = isset($event['SUMMARY']) ? $event['SUMMARY'] : 'no title'; | |
| if (isset($event['DTSTART_ALLDAY']) && $event['DTSTART_ALLDAY']) { | |
| $rangeTexts[] = "all day, $summary"; | |
| } else { | |
| $startHour = intval($event['DTSTART']->format('H')); | |
| $startTTS = hourToWords($startHour); | |
| $endTTS = ''; | |
| if (isset($event['DTEND'])) { | |
| $endHour = intval($event['DTEND']->format('H')); | |
| $endTTS = ' to ' . hourToWords($endHour); | |
| } | |
| $rangeTexts[] = "from $startTTS$endTTS, $summary"; | |
| } | |
| } | |
| return "Today's upcoming appointments are: " . implode("; ", $rangeTexts) . "."; | |
| } | |
| } | |
| function hourToWords($hour) { | |
| $hourWords = [ | |
| 0 => "midnight", 1 => "one", 2 => "two", 3 => "three", 4 => "four", | |
| 5 => "five", 6 => "six", 7 => "seven", 8 => "eight", 9 => "nine", | |
| 10 => "ten", 11 => "eleven", 12 => "noon", 13 => "one", 14 => "two", | |
| 15 => "three", 16 => "four", 17 => "five", 18 => "six", 19 => "seven", | |
| 20 => "eight", 21 => "nine", 22 => "ten", 23 => "eleven" | |
| ]; | |
| if ($hour == 0 || $hour == 12) return $hourWords[$hour]; | |
| return $hourWords[$hour] . ($hour < 12 ? " AM" : " PM"); | |
| } | |
| function saveImageToFile($image, $title) { | |
| $debugDir = './debug_images/'; | |
| if (!is_dir($debugDir)) mkdir($debugDir, 0755, true); | |
| $safeTitle = preg_replace('/[^a-zA-Z0-9]/', '_', $title); | |
| $filename = $debugDir . $safeTitle . '_' . date('Ymd_His') . '.png'; | |
| imagepng($image, $filename); | |
| return $filename; | |
| } | |
| function wrapText($text, $fontPath, $fontSize, $maxWidth) { | |
| $words = explode(' ', $text); | |
| $lines = []; | |
| $currentLine = ''; | |
| $maxCharsPerLine = 40; // Hard limit for long words | |
| foreach ($words as $word) { | |
| if (strlen($word) > $maxCharsPerLine) { | |
| // Split long words | |
| while (strlen($word) > $maxCharsPerLine) { | |
| if ($currentLine) $lines[] = $currentLine; | |
| $currentLine = substr($word, 0, $maxCharsPerLine - 1) . '-'; | |
| $word = substr($word, $maxCharsPerLine - 1); | |
| } | |
| } | |
| $testLine = $currentLine ? "$currentLine $word" : $word; | |
| $bbox = imagettfbbox($fontSize, 0, $fontPath, $testLine); | |
| $textWidth = $bbox[2] - $bbox[0]; | |
| if ($textWidth <= $maxWidth) { | |
| $currentLine = $testLine; | |
| } else { | |
| if ($currentLine) $lines[] = $currentLine; | |
| $currentLine = $word; | |
| } | |
| } | |
| if ($currentLine) $lines[] = $currentLine; | |
| return array_slice($lines, 0, 5); // Limit to 5 lines | |
| } | |
| function createTextImage($title, $onscreenText, $isDarkMode) { | |
| $width = 450; | |
| $height = 270; | |
| $marginLeft = 20; | |
| $marginTop = 20; | |
| $padding = 10; | |
| $image = imagecreatetruecolor($width, $height); | |
| // Colors | |
| $bgColor = $isDarkMode ? imagecolorallocate($image, 30, 30, 30) : imagecolorallocate($image, 240, 240, 240); | |
| $textColor = $isDarkMode ? imagecolorallocate($image, 220, 220, 220) : imagecolorallocate($image, 40, 40, 40); | |
| $boxBgColor = $isDarkMode ? imagecolorallocate($image, 50, 50, 50) : imagecolorallocate($image, 255, 255, 255); | |
| $boxBorderColor = $isDarkMode ? imagecolorallocate($image, 100, 100, 100) : imagecolorallocate($image, 200, 200, 200); | |
| $accentColor = $isDarkMode ? imagecolorallocate($image, 100, 149, 237) : imagecolorallocate($image, 0, 102, 204); | |
| // Font path (adjust as needed) | |
| $fontPath = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; | |
| if (!file_exists($fontPath)) { | |
| $fontPath = './DejaVuSans.ttf'; | |
| } | |
| // Fill background | |
| imagefill($image, 0, 0, $bgColor); | |
| // Draw title with underline | |
| $titleFontSize = 16; | |
| $titleBox = imagettfbbox($titleFontSize, 0, $fontPath, $title); | |
| $titleWidth = $titleBox[2] - $titleBox[0]; | |
| imagettftext($image, $titleFontSize, 0, $marginLeft, $marginTop + 14, $textColor, $fontPath, $title); | |
| imageline($image, $marginLeft, $marginTop + 20, $marginLeft + $titleWidth, $marginTop + 20, $accentColor); | |
| $lines = explode("\n", $onscreenText); | |
| $y = $marginTop + 40; | |
| $boxWidth = $width - 2 * $marginLeft + 5; // Wider box | |
| $fontSize = 10; // Further reduced font size | |
| $isSingleEvent = (strpos($title, "Next Upcoming Event") !== false); | |
| if ($isSingleEvent) { | |
| if ($lines[0] !== 'No upcoming events.') { | |
| // Wrap each line to fit within box | |
| $wrappedLines = []; | |
| foreach ($lines as $line) { | |
| $wrappedLines = array_merge($wrappedLines, wrapText($line, $fontPath, $fontSize, $boxWidth - 2 * $padding)); | |
| } | |
| // Calculate box height based on wrapped lines | |
| $lineHeight = 16; | |
| $boxHeight = min(count($wrappedLines) * $lineHeight + 2 * $padding, $height - $y - 10); | |
| imagefilledrectangle($image, $marginLeft, $y, $marginLeft + $boxWidth, $y + $boxHeight, $boxBgColor); | |
| imagerectangle($image, $marginLeft, $y, $marginLeft + $boxWidth, $y + $boxHeight, $boxBorderColor); | |
| foreach ($wrappedLines as $index => $line) { | |
| if ($y + $padding + 14 + $index * $lineHeight > $height - 10) break; | |
| imagettftext($image, $fontSize, 0, $marginLeft + $padding, $y + $padding + 14 + $index * $lineHeight, $textColor, $fontPath, $line); | |
| } | |
| $y += $boxHeight; | |
| } else { | |
| imagettftext($image, $fontSize, 0, $marginLeft, $y + 14, $textColor, $fontPath, $lines[0]); | |
| } | |
| } else { | |
| $eventLines = array_slice($lines, 1); | |
| if (empty($eventLines) || $eventLines[0] === 'None') { | |
| imagettftext($image, $fontSize, 0, $marginLeft, $y + 14, $textColor, $fontPath, 'None'); | |
| } else { | |
| foreach ($eventLines as $line) { | |
| $wrappedLines = wrapText($line, $fontPath, $fontSize, $boxWidth - 2 * $padding); | |
| $boxHeight = count($wrappedLines) * 16 + 2 * $padding; | |
| if ($y + $boxHeight > $height - 10) break; | |
| imagefilledrectangle($image, $marginLeft, $y, $marginLeft + $boxWidth, $y + $boxHeight, $boxBgColor); | |
| imagerectangle($image, $marginLeft, $y, $marginLeft + $boxWidth, $y + $boxHeight, $boxBorderColor); | |
| foreach ($wrappedLines as $index => $wrappedLine) { | |
| imagettftext($image, $fontSize, 0, $marginLeft + $padding, $y + $padding + 10 + $index * 16, $textColor, $fontPath, $wrappedLine); | |
| } | |
| $y += $boxHeight + 10; | |
| } | |
| } | |
| } | |
| // Save image to file for debugging | |
| //saveImageToFile($image, $title); | |
| // Generate base64 for JSON output | |
| ob_start(); | |
| imagepng($image); | |
| $imageData = ob_get_contents(); | |
| ob_end_clean(); | |
| imagedestroy($image); | |
| return base64_encode($imageData); | |
| } | |
| // Main execution | |
| $slides = []; | |
| if (!isset($_GET['ics'])) { | |
| header('Content-Type: application/json'); | |
| echo json_encode($slides); | |
| exit; | |
| } | |
| $icsUrl = $_GET['ics']; | |
| $icsContent = fetchICS($icsUrl); | |
| if ($icsContent) { | |
| $events = parseICS($icsContent); | |
| $eventData = getUpcomingEvents($events); | |
| $nextEvent = $eventData['next']; | |
| $todayEvents = $eventData['today']; | |
| // First slide: Next upcoming event | |
| if ($nextEvent) { | |
| $locationData = extractLocationData($nextEvent); | |
| $nextOnScreen = generateOnScreenText($nextEvent, true); | |
| $nextTTS = generateTTSText($nextEvent, true); | |
| $nextImg = createTextImage("Next Upcoming Event", $nextOnScreen, $isDarkMode); | |
| $slide = [ | |
| "title1" => "Next Upcoming Event", | |
| "title2" => "Next Upcoming Event", | |
| "title3" => "", | |
| "onscreen" => $nextOnScreen, | |
| "tts" => $nextTTS, | |
| "img_base64" => $nextImg, | |
| "bell" => true, | |
| "save" => true | |
| ]; | |
| if ($locationData['phone_number'] != null && strlen($locationData['phone_number']) > 0) { | |
| $slide['phone_number'] = $locationData['phone_number']; | |
| } | |
| if ($locationData['map_point'] !== null) { | |
| $slide['map_point'] = $locationData['map_point']; | |
| } | |
| $slides[] = $slide; | |
| } | |
| // Second slide: Today's upcoming events | |
| $todayOnScreen = generateOnScreenText($todayEvents); | |
| $todayTTS = generateTTSText($todayEvents); | |
| $todayImg = createTextImage("Today's Upcoming Events", $todayOnScreen, $isDarkMode); | |
| $slides[] = [ | |
| "title1" => "Today's Upcoming Events", | |
| "title2" => "Today's Upcoming Events", | |
| "title3" => "", | |
| "onscreen" => $todayOnScreen, | |
| "tts" => $todayTTS, | |
| "img_base64" => $todayImg, | |
| "bell" => true, | |
| "save" => true | |
| ]; | |
| } | |
| // Output JSON for slideshow | |
| header('Content-Type: application/json'); | |
| echo json_encode($slides); | |
| ?> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment