calender stuff
This commit is contained in:
1 parent
8e78468743
commit
e0fca064ae
15 files changed
+1716
-67
No files matched your search
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
class CalDavClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $username;
|
||||
private string $password;
|
||||
|
||||
public function __construct(string $baseUrl, string $username, string $password)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl, '/') . '/';
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string { return $this->baseUrl; }
|
||||
|
||||
public function request(string $method, string $url, string $body = '', array $headers = []): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_USERPWD => $this->username . ':' . $this->password,
|
||||
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
if ($body !== '') {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
// Use caller's Content-Type if provided, otherwise default to XML
|
||||
$hasContentType = false;
|
||||
foreach ($headers as $h) { if (stripos($h, 'Content-Type:') === 0) { $hasContentType = true; break; } }
|
||||
$allHeaders = $hasContentType ? $headers : array_merge(['Content-Type: application/xml; charset=utf-8'], $headers);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $allHeaders);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'headers' => '', 'body' => '', 'error' => $error];
|
||||
}
|
||||
|
||||
$respHeaders = substr($response, 0, $headerSize);
|
||||
$respBody = substr($response, $headerSize);
|
||||
|
||||
// Extract ETag from headers
|
||||
$etag = null;
|
||||
if (preg_match('/^ETag:\s*(.+)$/mi', $respHeaders, $m)) {
|
||||
$etag = trim($m[1]);
|
||||
}
|
||||
|
||||
return ['status' => $status, 'headers' => $respHeaders, 'body' => $respBody, 'etag' => $etag, 'error' => null];
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
|
||||
return $res['status'] === 207;
|
||||
}
|
||||
|
||||
public function listCalendars(): array
|
||||
{
|
||||
// PROPFIND depth 1 on the base URL to find calendar collections underneath
|
||||
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/><cs:getctag/></d:prop></d:propfind>';
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 1']);
|
||||
if ($res['status'] !== 207) return [];
|
||||
|
||||
$baseHref = rtrim(parse_url($this->baseUrl, PHP_URL_PATH), '/');
|
||||
$calendars = [];
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
foreach ($xml->xpath('//d:response') as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
|
||||
// Skip the collection itself (same path as base)
|
||||
if (rtrim($href, '/') === $baseHref) continue;
|
||||
|
||||
// Check for calendar resource type via namespace-aware xpath
|
||||
$isCal = $resp->xpath('.//d:resourcetype/c:calendar');
|
||||
if (!$isCal) {
|
||||
// Fallback: check raw XML for 'calendar' string
|
||||
$rtXml = $resp->xpath('.//d:resourcetype');
|
||||
if (!$rtXml || strpos($rtXml[0]->asXML(), 'calendar') === false) continue;
|
||||
}
|
||||
|
||||
$name = (string) ($resp->xpath('.//d:displayname')[0] ?? basename(rtrim($href, '/')));
|
||||
$ctag = (string) ($resp->xpath('.//cs:getctag')[0] ?? '');
|
||||
if ($href) $calendars[] = ['name' => $name, 'href' => $href, 'ctag' => $ctag];
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
return $calendars;
|
||||
}
|
||||
|
||||
public function getCalendarCtag(): ?string
|
||||
{
|
||||
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>';
|
||||
$res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 0']);
|
||||
if ($res['status'] !== 207) return null;
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
$ctag = $xml->xpath('//cs:getctag');
|
||||
return $ctag ? (string) $ctag[0] : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function listEvents(): array
|
||||
{
|
||||
$body = '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>';
|
||||
$res = $this->request('REPORT', $this->baseUrl, $body, ['Depth: 1']);
|
||||
if ($res['status'] !== 207) return [];
|
||||
|
||||
$events = [];
|
||||
try {
|
||||
$xml = new SimpleXMLElement($res['body']);
|
||||
$xml->registerXPathNamespace('d', 'DAV:');
|
||||
$xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
foreach ($xml->xpath('//d:response') as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
$href = (string) ($resp->xpath('.//d:href')[0] ?? '');
|
||||
$etag = (string) ($resp->xpath('.//d:getetag')[0] ?? '');
|
||||
$ical = (string) ($resp->xpath('.//c:calendar-data')[0] ?? '');
|
||||
if ($href && $ical) {
|
||||
$events[] = ['href' => $href, 'etag' => $etag, 'ical' => $ical];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
return $events;
|
||||
}
|
||||
|
||||
public function createEvent(string $uid, string $icalData): array
|
||||
{
|
||||
// Use URL-safe filename (replace @ and other special chars)
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid);
|
||||
$url = $this->baseUrl . $filename . '.ics';
|
||||
return $this->request('PUT', $url, $icalData, [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
'If-None-Match: *',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEvent(string $href, string $icalData, string $etag): array
|
||||
{
|
||||
$url = $this->resolveHref($href);
|
||||
return $this->request('PUT', $url, $icalData, [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
'If-Match: ' . $etag,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteEvent(string $href, string $etag): array
|
||||
{
|
||||
$url = $this->resolveHref($href);
|
||||
return $this->request('DELETE', $url, '', ['If-Match: ' . $etag]);
|
||||
}
|
||||
|
||||
private function resolveHref(string $href): string
|
||||
{
|
||||
if (str_starts_with($href, 'http')) return $href;
|
||||
$parsed = parse_url($this->baseUrl);
|
||||
return $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '') . $href;
|
||||
}
|
||||
|
||||
// ── iCalendar parsing ──
|
||||
|
||||
public static function parseVEvent(string $ical): ?array
|
||||
{
|
||||
// Unfold RFC 5545 line continuations
|
||||
$ical = preg_replace('/\r\n[ \t]/', '', $ical);
|
||||
$ical = str_replace("\r\n", "\n", $ical);
|
||||
|
||||
if (!preg_match('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $m)) return null;
|
||||
$block = $m[1];
|
||||
|
||||
$get = function ($prop) use ($block) {
|
||||
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:(.+)$/m', $block, $m)) return trim($m[1]);
|
||||
return null;
|
||||
};
|
||||
|
||||
return [
|
||||
'uid' => $get('UID'),
|
||||
'summary' => $get('SUMMARY'),
|
||||
'dtstart' => $get('DTSTART'),
|
||||
'dtend' => $get('DTEND'),
|
||||
'location' => $get('LOCATION'),
|
||||
'description' => str_replace('\\n', "\n", $get('DESCRIPTION') ?? ''),
|
||||
'last_modified' => $get('LAST-MODIFIED'),
|
||||
'sequence' => (int) ($get('SEQUENCE') ?? 0),
|
||||
'rrule' => $get('RRULE'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildVEvent(array $props): string
|
||||
{
|
||||
$fold = function ($line) {
|
||||
$out = '';
|
||||
while (strlen($line) > 75) {
|
||||
$out .= substr($line, 0, 75) . "\r\n ";
|
||||
$line = substr($line, 75);
|
||||
}
|
||||
return $out . $line;
|
||||
};
|
||||
|
||||
$escape = function ($s) {
|
||||
return str_replace(["\n", ",", ";", "\\"], ["\\n", "\\,", "\\;", "\\\\"], $s ?? '');
|
||||
};
|
||||
|
||||
$lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//hyprcrd//CalDAV Client//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:' . $props['uid'],
|
||||
];
|
||||
|
||||
// DTSTART / DTEND
|
||||
if (strlen($props['dtstart'] ?? '') === 8) {
|
||||
$lines[] = 'DTSTART;VALUE=DATE:' . $props['dtstart'];
|
||||
$lines[] = 'DTEND;VALUE=DATE:' . ($props['dtend'] ?? $props['dtstart']);
|
||||
} else {
|
||||
$lines[] = 'DTSTART:' . $props['dtstart'];
|
||||
if (!empty($props['dtend'])) $lines[] = 'DTEND:' . $props['dtend'];
|
||||
}
|
||||
|
||||
$lines[] = $fold('SUMMARY:' . $escape($props['summary'] ?? ''));
|
||||
if (!empty($props['rrule'])) $lines[] = 'RRULE:' . $props['rrule'];
|
||||
if (!empty($props['location'])) $lines[] = $fold('LOCATION:' . $escape($props['location']));
|
||||
if (!empty($props['description'])) $lines[] = $fold('DESCRIPTION:' . $escape($props['description']));
|
||||
$lines[] = 'SEQUENCE:' . ($props['sequence'] ?? 0);
|
||||
$lines[] = 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z');
|
||||
$lines[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');
|
||||
if (!empty($props['color'])) $lines[] = 'X-HYPRCRD-COLOR:' . $props['color'];
|
||||
$lines[] = 'END:VEVENT';
|
||||
$lines[] = 'END:VCALENDAR';
|
||||
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/CalDavClient.php';
|
||||
|
||||
class CalDavSync
|
||||
{
|
||||
private PDO $db;
|
||||
private ?CalDavClient $client = null;
|
||||
private array $log = [];
|
||||
private array $stats = ['pushed' => 0, 'pulled' => 0, 'deleted' => 0, 'conflicts' => 0];
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ── Encryption helpers ──
|
||||
|
||||
private function getEncKey(): string
|
||||
{
|
||||
$hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
|
||||
private function encrypt(string $plain): string
|
||||
{
|
||||
$key = $this->getEncKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, 0, $iv);
|
||||
return base64_encode($iv . base64_decode($cipher));
|
||||
}
|
||||
|
||||
private function decrypt(string $encrypted): string
|
||||
{
|
||||
$key = $this->getEncKey();
|
||||
$raw = base64_decode($encrypted);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = base64_encode(substr($raw, 16));
|
||||
return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: '';
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
public function getSetting(string $key): ?string
|
||||
{
|
||||
return $this->db->query("SELECT value FROM settings WHERE key = 'caldav_{$key}'")->fetchColumn() ?: null;
|
||||
}
|
||||
|
||||
public function setSetting(string $key, ?string $value): void
|
||||
{
|
||||
$this->db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)")
|
||||
->execute(['k' => "caldav_{$key}", 'v' => $value ?? '']);
|
||||
}
|
||||
|
||||
public function getSettings(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->getSetting('enabled') === '1',
|
||||
'url' => $this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : '',
|
||||
'username' => $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '',
|
||||
'has_password' => !empty($this->getSetting('password')),
|
||||
'calendar' => $this->getSetting('calendar') ?? '',
|
||||
'last_sync' => $this->getSetting('last_sync') ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function saveSettings(array $input): void
|
||||
{
|
||||
if (isset($input['url'])) $this->setSetting('url', $this->encrypt($input['url']));
|
||||
if (isset($input['username'])) $this->setSetting('username', $this->encrypt($input['username']));
|
||||
if (!empty($input['password'])) $this->setSetting('password', $this->encrypt($input['password']));
|
||||
if (isset($input['calendar'])) $this->setSetting('calendar', $input['calendar']);
|
||||
if (isset($input['enabled'])) $this->setSetting('enabled', $input['enabled'] ? '1' : '0');
|
||||
}
|
||||
|
||||
// ── Client ──
|
||||
|
||||
private function getClient(?string $url = null, ?string $user = null, ?string $pass = null): CalDavClient
|
||||
{
|
||||
if (!$this->client) {
|
||||
$this->client = new CalDavClient(
|
||||
$url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : ''),
|
||||
$user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''),
|
||||
$pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '')
|
||||
);
|
||||
}
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function testConnection(?string $url = null, ?string $user = null, ?string $pass = null): array
|
||||
{
|
||||
$log = [];
|
||||
try {
|
||||
$resolvedUrl = $url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : '');
|
||||
$resolvedUser = $user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '');
|
||||
$resolvedPass = $pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '');
|
||||
|
||||
$log[] = 'URL: ' . preg_replace('#(https?://)[^@/]+:[^@/]+@#', '$1***:***@', $resolvedUrl);
|
||||
$log[] = 'User: ' . $resolvedUser;
|
||||
|
||||
if (!$resolvedUrl) { $log[] = 'ERROR: no URL configured'; return ['ok' => false, 'error' => 'no URL', 'log' => $log]; }
|
||||
if (!$resolvedUser) { $log[] = 'ERROR: no username'; return ['ok' => false, 'error' => 'no username', 'log' => $log]; }
|
||||
if (!$resolvedPass) { $log[] = 'ERROR: no password'; return ['ok' => false, 'error' => 'no password', 'log' => $log]; }
|
||||
|
||||
$client = new CalDavClient($resolvedUrl, $resolvedUser, $resolvedPass);
|
||||
|
||||
$log[] = 'PROPFIND depth 0...';
|
||||
$res = $client->request('PROPFIND', $resolvedUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
|
||||
$log[] = "HTTP {$res['status']}" . ($res['error'] ? " — {$res['error']}" : '');
|
||||
|
||||
if ($res['status'] === 0) {
|
||||
$log[] = 'ERROR: could not connect (check URL, network, SSL)';
|
||||
return ['ok' => false, 'error' => $res['error'] ?: 'connection failed', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] === 401) {
|
||||
$log[] = 'ERROR: authentication failed (check username/password)';
|
||||
return ['ok' => false, 'error' => '401 unauthorized', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] === 404) {
|
||||
$log[] = 'ERROR: calendar not found (check URL path)';
|
||||
return ['ok' => false, 'error' => '404 not found', 'log' => $log];
|
||||
}
|
||||
if ($res['status'] !== 207) {
|
||||
$log[] = 'ERROR: unexpected status ' . $res['status'];
|
||||
$log[] = substr($res['body'], 0, 300);
|
||||
return ['ok' => false, 'error' => "HTTP {$res['status']}", 'log' => $log];
|
||||
}
|
||||
|
||||
$log[] = 'OK — listing calendars (PROPFIND depth 1)...';
|
||||
$calendars = $client->listCalendars();
|
||||
$log[] = count($calendars) . ' calendar(s) found';
|
||||
foreach ($calendars as $c) $log[] = " - {$c['name']} ({$c['href']})";
|
||||
if (!$calendars) {
|
||||
// Debug: show raw PROPFIND response
|
||||
$debugRes = $client->request('PROPFIND', $resolvedUrl, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>', ['Depth: 1']);
|
||||
$log[] = 'DEBUG depth 1 status: ' . $debugRes['status'];
|
||||
// Count responses in XML
|
||||
$responseCount = substr_count($debugRes['body'], '<d:response');
|
||||
if (!$responseCount) $responseCount = substr_count($debugRes['body'], ':response');
|
||||
$log[] = "DEBUG responses in XML: {$responseCount}";
|
||||
$log[] = 'DEBUG body (first 500): ' . substr(preg_replace('/\s+/', ' ', $debugRes['body']), 0, 500);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'calendars' => $calendars, 'log' => $log];
|
||||
} catch (Exception $e) {
|
||||
$log[] = 'EXCEPTION: ' . $e->getMessage();
|
||||
return ['ok' => false, 'error' => $e->getMessage(), 'log' => $log];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field mapping ──
|
||||
|
||||
public function cardToVEvent(array $card): string
|
||||
{
|
||||
$title = $card['title'] ?? '';
|
||||
$body = $card['body'] ?? '';
|
||||
|
||||
// Parse title: "YYYY-MM-DD — Subject" or "YYYY-MM-DD"
|
||||
preg_match('/^(\d{4}-\d{2}-\d{2})\s*(?:—|-)\s*(.*)$/', $title, $tm);
|
||||
$date = $tm[1] ?? substr($title, 0, 10);
|
||||
$summary = trim($tm[2] ?? $title);
|
||||
|
||||
// Parse body fields
|
||||
$time = ''; $timeEnd = '';
|
||||
if (preg_match('/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})(?:\s*-\s*(\d{1,2}:\d{2}))?/i', $body, $zm)) {
|
||||
$time = $zm[1];
|
||||
$timeEnd = $zm[2] ?? '';
|
||||
}
|
||||
$location = '';
|
||||
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
||||
$description = '';
|
||||
if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?:\n\n#|\z)/i', $body, $nm)) $description = trim($nm[1]);
|
||||
|
||||
// Build DTSTART/DTEND
|
||||
$dateCompact = str_replace('-', '', $date);
|
||||
if ($time) {
|
||||
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
||||
if ($timeEnd) {
|
||||
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
||||
} else {
|
||||
// Default: +1 hour
|
||||
$ts = strtotime($date . ' ' . $time);
|
||||
$dtend = date('Ymd\THis', $ts + 3600);
|
||||
}
|
||||
} else {
|
||||
$dtstart = $dateCompact;
|
||||
// All-day: DTEND = next day
|
||||
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
||||
}
|
||||
|
||||
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
||||
|
||||
// Detect birthday → add RRULE:FREQ=YEARLY
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
$isBirthday = in_array('geburtstag', $tags);
|
||||
$rrule = $card['caldav_rrule'] ?? ($isBirthday ? 'FREQ=YEARLY' : null);
|
||||
|
||||
return CalDavClient::buildVEvent([
|
||||
'uid' => $uid,
|
||||
'dtstart' => $dtstart,
|
||||
'dtend' => $dtend,
|
||||
'summary' => $summary,
|
||||
'location' => $location,
|
||||
'rrule' => $rrule,
|
||||
'description' => $description,
|
||||
'color' => $card['color'] ?? '#fb923c',
|
||||
'sequence' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function vEventToCard(array $vevent, ?array $existing = null, ?string $calendarName = null): array
|
||||
{
|
||||
$dt = $vevent['dtstart'] ?? '';
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) {
|
||||
$date = "{$dm[1]}-{$dm[2]}-{$dm[3]}";
|
||||
} else {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
|
||||
$time = '';
|
||||
$timeEnd = '';
|
||||
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
||||
$time = "{$hm[1]}:{$hm[2]}";
|
||||
}
|
||||
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
||||
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
||||
}
|
||||
|
||||
$summary = $vevent['summary'] ?? 'Termin';
|
||||
$title = $summary ? "{$date} — {$summary}" : $date;
|
||||
|
||||
// Determine tag from calendar name
|
||||
$isBirthdayCal = $calendarName && (stripos($calendarName, 'geburtstag') !== false || stripos($calendarName, 'birthday') !== false);
|
||||
$tag = $isBirthdayCal ? '#geburtstag' : '#termin';
|
||||
if ($calendarName && !$isBirthdayCal) {
|
||||
// Add calendar name as additional tag
|
||||
$calTag = strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', $calendarName));
|
||||
if ($calTag) $tag .= " #{$calTag}";
|
||||
}
|
||||
|
||||
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
||||
$body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
||||
|
||||
// Color: birthdays pink, others orange
|
||||
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
||||
|
||||
$card = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $existing['color'] ?? $defaultColor,
|
||||
'bucket_id' => $existing['bucket_id'] ?? null,
|
||||
'parent_id' => $existing['parent_id'] ?? null,
|
||||
'position' => $existing['position'] ?? -1,
|
||||
];
|
||||
|
||||
return $card;
|
||||
}
|
||||
|
||||
private function eventDate(array $vevent): ?string
|
||||
{
|
||||
$dt = $vevent['dtstart'] ?? '';
|
||||
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) {
|
||||
return "{$dm[1]}-{$dm[2]}-{$dm[3]}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveCalendarUrls(CalDavClient $client): array
|
||||
{
|
||||
$parsed = parse_url($client->getBaseUrl());
|
||||
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
|
||||
|
||||
$calendars = $client->listCalendars();
|
||||
if (empty($calendars)) {
|
||||
return ['all' => [$client->getBaseUrl()], 'push' => $client->getBaseUrl(), 'push_birthday' => $client->getBaseUrl()];
|
||||
}
|
||||
|
||||
$allUrls = [];
|
||||
$pushUrl = null;
|
||||
$birthdayUrl = null;
|
||||
$preferred = $this->getSetting('calendar');
|
||||
|
||||
foreach ($calendars as $cal) {
|
||||
$url = $base . $cal['href'];
|
||||
$allUrls[] = $url;
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "found calendar: {$cal['name']}");
|
||||
if ($preferred && ($cal['name'] === $preferred || str_contains($cal['href'], $preferred))) {
|
||||
$pushUrl = $url;
|
||||
}
|
||||
if (stripos($cal['name'], 'geburtstag') !== false || stripos($cal['name'], 'birthday') !== false) {
|
||||
$birthdayUrl = $url;
|
||||
}
|
||||
}
|
||||
|
||||
// Default push: first non-birthday/non-contact calendar
|
||||
if (!$pushUrl) {
|
||||
foreach ($calendars as $cal) {
|
||||
if (stripos($cal['name'], 'geburtstag') === false && stripos($cal['name'], 'birthday') === false && stripos($cal['name'], 'contact') === false) {
|
||||
$pushUrl = $base . $cal['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$pushUrl) $pushUrl = $allUrls[0];
|
||||
}
|
||||
if (!$birthdayUrl) $birthdayUrl = $pushUrl;
|
||||
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "push termine: {$pushUrl}");
|
||||
$this->addLog('sync', null, null, 'init', 'ok', "push geburtstage: {$birthdayUrl}");
|
||||
return ['all' => $allUrls, 'push' => $pushUrl, 'push_birthday' => $birthdayUrl];
|
||||
}
|
||||
|
||||
private function makeClient(string $url): CalDavClient
|
||||
{
|
||||
$user = $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : '';
|
||||
$pass = $this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '';
|
||||
return new CalDavClient($url, $user, $pass);
|
||||
}
|
||||
|
||||
private function isBirthday(array $card): bool
|
||||
{
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
return in_array('geburtstag', $tags);
|
||||
}
|
||||
|
||||
private function getPushClient(array $card, CalDavClient $pushClient, CalDavClient $birthdayClient, array $allUrls): CalDavClient
|
||||
{
|
||||
if ($this->isBirthday($card)) return $birthdayClient;
|
||||
// Match calendar tag to URL slug
|
||||
$tags = array_map('trim', explode(',', $card['tags'] ?? ''));
|
||||
foreach ($tags as $tag) {
|
||||
if ($tag && $tag !== 'termin' && $tag !== 'geburtstag') {
|
||||
foreach ($allUrls as $url) {
|
||||
$slug = basename(rtrim($url, '/'));
|
||||
if ($slug === $tag) {
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', "tag #{$tag} → {$slug}");
|
||||
return $this->makeClient($url);
|
||||
}
|
||||
}
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'error', "tag #{$tag} no match in: " . implode(', ', array_map(fn($u) => basename(rtrim($u, '/')), $allUrls)));
|
||||
}
|
||||
}
|
||||
$this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', 'using default push calendar');
|
||||
return $pushClient;
|
||||
}
|
||||
|
||||
// ── Sync ──
|
||||
|
||||
private function addLog(string $dir, ?int $cardId, ?string $uid, string $action, string $status, string $detail = ''): void
|
||||
{
|
||||
$this->log[] = compact('dir', 'cardId', 'uid', 'action', 'status', 'detail');
|
||||
$this->db->prepare('INSERT INTO caldav_sync_log (direction, card_id, caldav_uid, action, status, detail) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
->execute([$dir, $cardId, $uid, $action, $status, $detail]);
|
||||
}
|
||||
|
||||
public function sync(): array
|
||||
{
|
||||
// Skip if no credentials configured
|
||||
$url = $this->getSetting('url');
|
||||
$pass = $this->getSetting('password');
|
||||
if (!$url || !$pass) {
|
||||
return ['ok' => true, 'log' => [], 'stats' => $this->stats];
|
||||
}
|
||||
|
||||
$baseClient = $this->getClient();
|
||||
|
||||
// 0. Resolve all calendars
|
||||
$urls = $this->resolveCalendarUrls($baseClient);
|
||||
if (empty($urls['all'])) {
|
||||
$this->addLog('sync', null, null, 'init', 'error', 'no calendar found — check URL');
|
||||
return ['ok' => false, 'error' => 'no calendar found', 'log' => $this->log, 'stats' => $this->stats];
|
||||
}
|
||||
$pushClient = $this->makeClient($urls['push']);
|
||||
$birthdayClient = $this->makeClient($urls['push_birthday']);
|
||||
|
||||
// 1-2. Fetch remote events from ALL calendars
|
||||
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
||||
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
||||
$remoteEvents = [];
|
||||
foreach ($urls['all'] as $calUrl) {
|
||||
$calSlug = basename(rtrim($calUrl, '/'));
|
||||
$cal = $this->makeClient($calUrl);
|
||||
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
||||
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
|
||||
$events = $cal->listEvents();
|
||||
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
||||
$parseFail = 0;
|
||||
foreach ($events as $ev) {
|
||||
$parsed = CalDavClient::parseVEvent($ev['ical']);
|
||||
if ($parsed && $parsed['uid']) {
|
||||
$data = array_merge($parsed, ['href' => $ev['href'], 'etag' => $ev['etag'], 'ical' => $ev['ical'], 'cal_url' => $calUrl]);
|
||||
$remoteMap[$parsed['uid']] = $data;
|
||||
$remoteAll[$parsed['uid']] = $data;
|
||||
$remoteEvents[] = $ev;
|
||||
} else {
|
||||
$parseFail++;
|
||||
}
|
||||
}
|
||||
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
|
||||
}
|
||||
|
||||
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
|
||||
|
||||
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
||||
$stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL");
|
||||
$localCards = $stmt->fetchAll();
|
||||
$localMap = []; // caldav_uid -> card
|
||||
$localNew = []; // cards without caldav_uid
|
||||
foreach ($localCards as $c) {
|
||||
if ($c['caldav_uid']) {
|
||||
$localMap[$c['caldav_uid']] = $c;
|
||||
} else {
|
||||
$localNew[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Tombstones
|
||||
$tombstones = [];
|
||||
foreach ($this->db->query('SELECT * FROM caldav_tombstones')->fetchAll() as $t) {
|
||||
$tombstones[$t['caldav_uid']] = $t;
|
||||
}
|
||||
|
||||
// 5. Reconciliation
|
||||
|
||||
// A: Local cards with UID — update or detect remote deletion
|
||||
foreach ($localMap as $uid => $card) {
|
||||
if (isset($remoteMap[$uid])) {
|
||||
$remote = $remoteMap[$uid];
|
||||
$remoteClient = $this->makeClient($remote['cal_url']);
|
||||
$localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
|
||||
$remoteChanged = $card['caldav_etag'] !== $remote['etag'];
|
||||
|
||||
// Always backfill rrule if missing locally but present remotely
|
||||
$remoteRRule = $remote['rrule'] ?? null;
|
||||
if ($remoteRRule && $card['caldav_rrule'] !== $remoteRRule) {
|
||||
$this->db->prepare('UPDATE cards SET caldav_rrule = ? WHERE id = ?')
|
||||
->execute([$remoteRRule, $card['id']]);
|
||||
$this->addLog('pull', $card['id'], $uid, 'rrule-fix', 'ok', $remoteRRule);
|
||||
}
|
||||
|
||||
if ($localChanged && $remoteChanged) {
|
||||
$localTime = strtotime($card['updated_at']);
|
||||
$remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0;
|
||||
if ($localTime >= $remoteTime) {
|
||||
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
||||
} else {
|
||||
$this->pullEvent($remote, $card);
|
||||
}
|
||||
$this->stats['conflicts']++;
|
||||
} elseif ($localChanged) {
|
||||
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);
|
||||
} elseif ($remoteChanged) {
|
||||
$this->pullEvent($remote, $card);
|
||||
}
|
||||
unset($remoteMap[$uid]);
|
||||
} else {
|
||||
// Event not on server — was it deleted remotely or never pushed?
|
||||
$localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
|
||||
$wasSynced = !empty($card['caldav_synced_at']);
|
||||
if ($localChanged) {
|
||||
// Local edit or never synced → re-push
|
||||
$ical = $this->cardToVEvent($card);
|
||||
$targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']);
|
||||
$res = $targetClient->createEvent($card['caldav_uid'], $ical);
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $card['caldav_uid']);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $filename . '.ics');
|
||||
$this->addLog('push', $card['id'], $uid, 'recreate', 'ok', $wasSynced ? 'was deleted, local edit wins' : 'first push');
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$this->addLog('push', $card['id'], $uid, 'recreate', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
} else {
|
||||
// No local changes + was synced before → remote intentionally deleted
|
||||
$title = $card['title'] ?? '';
|
||||
$this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]);
|
||||
$this->addLog('pull', $card['id'], $uid, 'delete', 'ok', "remote deleted: {$title}");
|
||||
$this->stats['deleted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B: New local cards → push to appropriate calendar
|
||||
foreach ($localNew as $card) {
|
||||
$uid = 'card-' . $card['id'] . '@hyprcrd';
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid);
|
||||
$ical = $this->cardToVEvent(array_merge($card, ['caldav_uid' => $uid]));
|
||||
$targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']);
|
||||
$targetUrl = $targetClient->getBaseUrl();
|
||||
$targetSlug = basename(rtrim($targetUrl, '/'));
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'ok', "→ {$targetSlug} ({$targetUrl}) tags={$card['tags']}");
|
||||
$res = $targetClient->createEvent($uid, $ical);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $uid, $res['etag'], $filename . '.ics');
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'ok', "done in {$targetSlug}");
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$putUrl = rtrim($targetUrl, '/') . '/' . $filename . '.ics';
|
||||
$this->addLog('push', $card['id'], $uid, 'create', 'error', "HTTP {$res['status']} PUT {$putUrl}" . ($res['error'] ? " — {$res['error']}" : ''));
|
||||
}
|
||||
}
|
||||
|
||||
// C: New remote events from ALL calendars → pull (skip past non-recurring)
|
||||
$today = date('Y-m-d');
|
||||
$skippedPast = 0;
|
||||
foreach ($remoteMap as $uid => $remote) {
|
||||
if (isset($tombstones[$uid])) continue;
|
||||
|
||||
// Skip past events (unless recurring)
|
||||
$evDate = $this->eventDate($remote);
|
||||
$isRecurring = !empty($remote['rrule']);
|
||||
if ($evDate && $evDate < $today && !$isRecurring) {
|
||||
$skippedPast++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$calName = basename(rtrim($remote['cal_url'] ?? '', '/'));
|
||||
$cardData = $this->vEventToCard($remote, null, $calName);
|
||||
$tags = $this->extractTags($cardData['body']);
|
||||
$stmt = $this->db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, caldav_uid, caldav_etag, caldav_href, caldav_rrule, caldav_synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime("now"))');
|
||||
$stmt->execute([$cardData['title'], $cardData['body'], $cardData['color'], $cardData['bucket_id'], $cardData['parent_id'], $cardData['position'], $tags, $uid, $remote['etag'], $remote['href'], $remote['rrule'] ?? null]);
|
||||
$this->addLog('pull', (int) $this->db->lastInsertId(), $uid, 'create', 'ok', "from {$calName}");
|
||||
$this->stats['pulled']++;
|
||||
}
|
||||
|
||||
if ($skippedPast) $this->addLog('sync', null, null, 'skip', 'ok', "{$skippedPast} past events skipped");
|
||||
|
||||
// D: Tombstones → delete from remote calendar
|
||||
foreach ($tombstones as $uid => $tomb) {
|
||||
if (isset($remoteAll[$uid])) {
|
||||
$remote = $remoteAll[$uid];
|
||||
$delClient = $this->makeClient($remote['cal_url']);
|
||||
$res = $delClient->deleteEvent($remote['href'], $remote['etag']);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', basename(rtrim($remote['cal_url'], '/')));
|
||||
$this->stats['deleted']++;
|
||||
} else {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
} else {
|
||||
$this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', 'already gone from server');
|
||||
}
|
||||
$this->db->prepare('DELETE FROM caldav_tombstones WHERE caldav_uid = ?')->execute([$uid]);
|
||||
}
|
||||
|
||||
// 6. Update sync metadata
|
||||
$this->setSetting('last_sync', date('Y-m-d H:i:s'));
|
||||
|
||||
return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats];
|
||||
}
|
||||
|
||||
private function pushCard(CalDavClient $client, array $card, string $href, string $etag): void
|
||||
{
|
||||
$ical = $this->cardToVEvent($card);
|
||||
$res = $client->updateEvent($href, $ical, $etag);
|
||||
if ($res['status'] >= 200 && $res['status'] < 300) {
|
||||
$this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $href);
|
||||
$this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'ok');
|
||||
$this->stats['pushed']++;
|
||||
} else {
|
||||
$this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'error', "HTTP {$res['status']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function pullEvent(array $remote, array $card): void
|
||||
{
|
||||
$calName = basename(rtrim($remote['cal_url'] ?? '', '/'));
|
||||
$cardData = $this->vEventToCard($remote, $card, $calName);
|
||||
$tags = $this->extractTags($cardData['body']);
|
||||
$this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, caldav_etag=?, caldav_href=?, caldav_rrule=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?')
|
||||
->execute([$cardData['title'], $cardData['body'], $tags, $remote['etag'], $remote['href'], $remote['rrule'] ?? null, $card['id']]);
|
||||
$this->addLog('pull', $card['id'], $remote['uid'], 'update', 'ok');
|
||||
$this->stats['pulled']++;
|
||||
}
|
||||
|
||||
private function updateCardSync(int $cardId, string $uid, ?string $etag, string $href): void
|
||||
{
|
||||
$this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?')
|
||||
->execute([$uid, $etag, $href, $cardId]);
|
||||
}
|
||||
|
||||
private function extractTags(string $body): string
|
||||
{
|
||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body, $matches);
|
||||
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
||||
}
|
||||
|
||||
private function findEventByUid(array $events, string $uid): ?array
|
||||
{
|
||||
foreach ($events as $ev) {
|
||||
if (str_contains($ev['ical'], 'UID:' . $uid)) return $ev;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,39 @@ class Database
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV columns
|
||||
if (!in_array('caldav_uid', $colNames)) {
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_uid TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_etag TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_href TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_synced_at TEXT DEFAULT NULL');
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!in_array('caldav_rrule', $colNames) && in_array('caldav_uid', $colNames)) {
|
||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_rrule TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
// CalDAV support tables
|
||||
self::$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS caldav_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
direction TEXT NOT NULL,
|
||||
card_id INTEGER,
|
||||
caldav_uid TEXT,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT DEFAULT "",
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
self::$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS caldav_tombstones (
|
||||
caldav_uid TEXT PRIMARY KEY,
|
||||
card_id INTEGER,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
|
||||
// Bucket color migration
|
||||
$bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll();
|
||||
$bcolNames = array_column($bcols, 'name');
|
||||
|
||||
Reference in new issue
Block a user