Skip to content

Instantly share code, notes, and snippets.

@developerfromjokela
Created October 17, 2025 20:03
Show Gist options
  • Select an option

  • Save developerfromjokela/33582a9f90e6bfa032808759e3451488 to your computer and use it in GitHub Desktop.

Select an option

Save developerfromjokela/33582a9f90e6bfa032808759e3451488 to your computer and use it in GitHub Desktop.
Calendar app for OpenCARWINGS via datachannel
<?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