false, 'error' => 'Admin access required']); return; } switch ($action) { case 'cleanup_empty_namespaces': $this->handleCleanupEmptyNamespaces(); break; case 'trim_all_past_recurring': $this->handleTrimAllPastRecurring(); break; case 'rescan_recurring': $this->handleRescanRecurring(); break; case 'extend_recurring': $this->handleExtendRecurring(); break; case 'trim_recurring': $this->handleTrimRecurring(); break; case 'pause_recurring': $this->handlePauseRecurring(); break; case 'resume_recurring': $this->handleResumeRecurring(); break; case 'change_start_recurring': $this->handleChangeStartRecurring(); break; case 'change_pattern_recurring': $this->handleChangePatternRecurring(); break; default: echo json_encode(['success' => false, 'error' => 'Unknown admin action']); } } public function handle() { global $INPUT; $action = $INPUT->str('action'); if ($action === 'clear_cache') { $this->clearCache(); } elseif ($action === 'save_config') { $this->saveConfig(); } elseif ($action === 'delete_recurring_series') { $this->deleteRecurringSeries(); } elseif ($action === 'edit_recurring_series') { $this->editRecurringSeries(); } elseif ($action === 'move_selected_events') { $this->moveEvents(); } elseif ($action === 'move_single_event') { $this->moveSingleEvent(); } elseif ($action === 'delete_selected_events') { $this->deleteSelectedEvents(); } elseif ($action === 'create_namespace') { $this->createNamespace(); } elseif ($action === 'delete_namespace') { $this->deleteNamespace(); } elseif ($action === 'rename_namespace') { $this->renameNamespace(); } elseif ($action === 'run_sync') { $this->runSync(); } elseif ($action === 'stop_sync') { $this->stopSync(); } elseif ($action === 'upload_update') { $this->uploadUpdate(); } elseif ($action === 'delete_backup') { $this->deleteBackup(); } elseif ($action === 'rename_backup') { $this->renameBackup(); } elseif ($action === 'restore_backup') { $this->restoreBackup(); } elseif ($action === 'create_manual_backup') { $this->createManualBackup(); } elseif ($action === 'export_config') { $this->exportConfig(); } elseif ($action === 'import_config') { $this->importConfig(); } elseif ($action === 'get_log') { $this->getLog(); } elseif ($action === 'cleanup_empty_namespaces') { $this->handleCleanupEmptyNamespaces(); } elseif ($action === 'trim_all_past_recurring') { $this->handleTrimAllPastRecurring(); } elseif ($action === 'rescan_recurring') { $this->handleRescanRecurring(); } elseif ($action === 'extend_recurring') { $this->handleExtendRecurring(); } elseif ($action === 'trim_recurring') { $this->handleTrimRecurring(); } elseif ($action === 'pause_recurring') { $this->handlePauseRecurring(); } elseif ($action === 'resume_recurring') { $this->handleResumeRecurring(); } elseif ($action === 'change_start_recurring') { $this->handleChangeStartRecurring(); } elseif ($action === 'change_pattern_recurring') { $this->handleChangePatternRecurring(); } elseif ($action === 'clear_log') { $this->clearLogFile(); } elseif ($action === 'download_log') { $this->downloadLog(); } elseif ($action === 'rescan_events') { $this->rescanEvents(); } elseif ($action === 'export_all_events') { $this->exportAllEvents(); } elseif ($action === 'import_all_events') { $this->importAllEvents(); } elseif ($action === 'preview_cleanup') { $this->previewCleanup(); } elseif ($action === 'cleanup_events') { $this->cleanupEvents(); } elseif ($action === 'save_important_namespaces') { $this->saveImportantNamespaces(); } } public function html() { global $INPUT; // Get current tab - default to 'manage' (Manage Events tab) $tab = $INPUT->str('tab', 'manage'); // Get template colors $colors = $this->getTemplateColors(); $accentColor = '#00cc07'; // Keep calendar plugin accent color // Tab navigation (Manage Events, Update Plugin, Outlook Sync, Themes) echo '
chmod 666 ' . $logFile . '';
echo '' . hsc($cronStatus['full_line']) . ''; echo '
To enable automatic syncing, add to crontab: */30 * * * * cd ' . DOKU_PLUGIN . 'calendar && php sync_outlook.php
Updates every 2 seconds
'; // Log viewer container echo ''; echo 'Scan, export, and import all calendar events across all namespaces.
'; // Get event statistics $stats = $this->getEventStatistics(); // Statistics display echo '| Namespace | '; echo 'Events | '; echo 'Files | '; echo '
|---|---|---|
' . hsc($ns ?: '(default)') . ' | ';
echo '' . $nsStats['events'] . ' | '; echo '' . $nsStats['files'] . ' | '; echo '
Events from these namespaces will be highlighted in purple in the sidebar widget\'s "Important Events" section.
'; echo ''; echo 'Comma-separated list of namespace names
'; echo 'Delete events based on criteria below. Automatic backup created before deletion.
'; echo ''; // Preview results area echo ''; echo ''; echo 'Select events and move between namespaces. Drag & drop also supported.
'; // Search bar echo '' . DOKU_PLUGIN . 'calendar/โ Permissions: OK - ready to update
'; } else { echo 'โ Permissions: Issues detected
'; if (!$pluginWritable) { echo 'Plugin directory not writable
'; } if (!$parentWritable) { echo 'Parent directory not writable
'; } echo 'Fix with: chmod -R 755 ' . DOKU_PLUGIN . 'calendar/
Or: chown -R www-data:www-data ' . DOKU_PLUGIN . 'calendar/
Upload a calendar plugin ZIP file to update. Your configuration will be preserved.
'; echo 'calendar.backup.vX.X.X.YYYY-MM-DD_HH-MM-SS.zipNo versions found in changelog
'; } } else { echo 'Changelog not available
'; } echo '| Backup File | '; echo 'Size | '; echo 'Actions | '; echo '
|---|---|---|
' . hsc($filename) . ' | ';
echo '' . $size . ' | '; echo ''; echo '๐ฅ Download'; echo ''; echo ''; echo ''; echo ' | '; echo '
No backups yet. Click "Create Backup Now" to create your first backup.
'; } echo 'No recurring events found.
'; return; } // Search bar echo '| Title โ | '; echo 'Namespace โ | '; echo 'Pattern โ | '; echo 'First โ | '; echo 'Count โ | '; echo 'Source โ | '; echo 'Actions | '; echo '
|---|---|---|---|---|---|---|
| ' . hsc($series['title']) . ' | '; echo '' . hsc($series['namespace'] ?: '(default)') . ' | ';
echo '' . hsc($series['pattern']) . ' | '; echo '' . hsc($series['firstDate']) . ' | '; echo '' . $series['count'] . ' | '; echo '' . $sourceLabel . ' | '; echo ''; $jsTitle = hsc(addslashes($series['title'])); $jsNs = hsc($series['namespace']); $jsCount = $series['count']; $jsFirst = hsc($series['firstDate']); $jsPattern = hsc($series['pattern']); $jsHasFlag = $series['hasFlag'] ? 'true' : 'false'; echo ''; echo ''; echo ''; echo ' | '; echo '
Total: ' . count($recurringEvents) . ' series
'; } /** * AJAX handler: rescan recurring events and return HTML */ private function handleCleanupEmptyNamespaces() { global $INPUT; $dryRun = $INPUT->bool('dry_run', false); $metaDir = DOKU_INC . 'data/meta/'; $details = []; $removedDirs = 0; $removedCalDirs = 0; // 1. Find all calendar/ subdirectories anywhere under data/meta/ $allCalDirs = []; $this->findAllCalendarDirsRecursive($metaDir, $allCalDirs); // 2. Check each calendar dir for empty JSON files foreach ($allCalDirs as $calDir) { $jsonFiles = glob($calDir . '/*.json'); $hasEvents = false; foreach ($jsonFiles as $jsonFile) { $data = json_decode(file_get_contents($jsonFile), true); if ($data && is_array($data)) { // Check if any date key has actual events foreach ($data as $dateKey => $events) { if (is_array($events) && !empty($events)) { $hasEvents = true; break 2; } } // JSON file has data but all dates are empty โ remove it if (!$dryRun) unlink($jsonFile); } } // Re-check after cleaning empty JSON files if (!$dryRun) { $jsonFiles = glob($calDir . '/*.json'); } // Derive display name from path $relPath = str_replace($metaDir, '', $calDir); $relPath = rtrim(str_replace('/calendar', '', $relPath), '/'); $displayName = $relPath ?: '(root)'; if ($displayName === '(root)') continue; // Never remove root calendar dir if (!$hasEvents || empty($jsonFiles)) { $removedCalDirs++; $details[] = "Remove empty calendar folder: " . $displayName . "/calendar/ (0 events)"; if (!$dryRun) { // Remove all remaining files in calendar dir foreach (glob($calDir . '/*') as $f) { if (is_file($f)) unlink($f); } @rmdir($calDir); // Check if parent namespace dir is now empty too $parentDir = dirname($calDir); if ($parentDir !== $metaDir && is_dir($parentDir)) { $remaining = array_diff(scandir($parentDir), ['.', '..']); if (empty($remaining)) { @rmdir($parentDir); $removedDirs++; $details[] = "Removed empty namespace directory: " . $displayName . "/"; } } } } } // 3. Also scan for namespace dirs that have a calendar/ subdir with 0 json files // (already covered above, but also check for namespace dirs without calendar/ at all // that are tracked in the event system) $total = $removedCalDirs + $removedDirs; $message = $dryRun ? "Found $total item(s) to clean up" : "Cleaned up $removedCalDirs empty calendar folder(s)" . ($removedDirs > 0 ? " and $removedDirs empty namespace directory(ies)" : ""); if (!$dryRun) $this->clearStatsCache(); echo json_encode([ 'success' => true, 'count' => $total, 'message' => $message, 'details' => $details ]); } /** * Recursively find all 'calendar' directories under a base path */ private function findAllCalendarDirsRecursive($baseDir, &$results) { $entries = glob($baseDir . '*', GLOB_ONLYDIR); if (!$entries) return; foreach ($entries as $dir) { $name = basename($dir); if ($name === 'calendar') { $results[] = $dir; } else { // Check for calendar subdir if (is_dir($dir . '/calendar')) { $results[] = $dir . '/calendar'; } // Recurse into subdirectories for nested namespaces $this->findAllCalendarDirsRecursive($dir . '/', $results); } } } private function handleTrimAllPastRecurring() { global $INPUT; $dryRun = $INPUT->bool('dry_run', false); $today = date('Y-m-d'); $dataDir = DOKU_INC . 'data/meta/'; $calendarDirs = []; if (is_dir($dataDir . 'calendar')) { $calendarDirs[] = $dataDir . 'calendar'; } $this->findCalendarDirs($dataDir, $calendarDirs); $removed = 0; foreach ($calendarDirs as $calDir) { foreach (glob($calDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { if ($dateKey >= $today) continue; if (!is_array($dayEvents)) continue; $filtered = []; foreach ($dayEvents as $event) { if (!empty($event['recurring']) || !empty($event['recurringId'])) { $removed++; if (!$dryRun) $modified = true; } else { $filtered[] = $event; } } if (!$dryRun) $dayEvents = $filtered; } unset($dayEvents); if (!$dryRun && $modified) { foreach ($data as $dk => $evts) { if (empty($evts)) unset($data[$dk]); } if (empty($data)) { unlink($file); } else { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } } } if (!$dryRun) $this->clearStatsCache(); echo json_encode(['success' => true, 'count' => $removed, 'message' => "Removed $removed past recurring occurrences"]); } private function handleRescanRecurring() { $colors = $this->getTemplateColors(); $recurringEvents = $this->findRecurringEvents(); ob_start(); $this->renderRecurringTable($recurringEvents, $colors); $html = ob_get_clean(); echo json_encode([ 'success' => true, 'html' => $html, 'count' => count($recurringEvents) ]); } /** * Helper: find all events matching a title in a namespace's calendar dir */ private function getRecurringSeriesEvents($title, $namespace) { $dataDir = DOKU_INC . 'data/meta/'; if ($namespace !== '') { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; $events = []; // ['date' => dateKey, 'file' => filepath, 'event' => eventData, 'index' => idx] if (!is_dir($dataDir)) return $events; foreach (glob($dataDir . '*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data || !is_array($data)) continue; foreach ($data as $dateKey => $dayEvents) { if (!is_array($dayEvents)) continue; foreach ($dayEvents as $idx => $event) { if (strtolower(trim($event['title'])) === strtolower(trim($title))) { $events[] = [ 'date' => $dateKey, 'file' => $file, 'event' => $event, 'index' => $idx ]; } } } } // Sort by date usort($events, function($a, $b) { return strcmp($a['date'], $b['date']); }); return $events; } /** * Extend series: add more future occurrences */ private function handleExtendRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); $count = $INPUT->int('count', 4); $intervalDays = $INPUT->int('interval_days', 7); $events = $this->getRecurringSeriesEvents($title, $namespace); if (empty($events)) { echo json_encode(['success' => false, 'error' => 'Series not found']); return; } // Use last event as template $lastEvent = end($events); $lastDate = new DateTime($lastEvent['date']); $template = $lastEvent['event']; $dataDir = DOKU_INC . 'data/meta/'; if ($namespace !== '') { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; if (!is_dir($dataDir)) mkdir($dataDir, 0755, true); $added = 0; $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); $maxExistingIdx = 0; foreach ($events as $e) { if (isset($e['event']['id']) && preg_match('/-(\d+)$/', $e['event']['id'], $m)) { $maxExistingIdx = max($maxExistingIdx, (int)$m[1]); } } for ($i = 1; $i <= $count; $i++) { $newDate = clone $lastDate; $newDate->modify('+' . ($i * $intervalDays) . ' days'); $dateKey = $newDate->format('Y-m-d'); list($year, $month) = explode('-', $dateKey); $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; if (!is_array($fileData)) $fileData = []; if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; $newEvent = $template; $newEvent['id'] = $baseId . '-' . ($maxExistingIdx + $i); $newEvent['recurring'] = true; $newEvent['recurringId'] = $baseId; $newEvent['created'] = date('Y-m-d H:i:s'); unset($newEvent['completed']); $newEvent['completed'] = false; $fileData[$dateKey][] = $newEvent; file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); $added++; } $this->clearStatsCache(); echo json_encode(['success' => true, 'message' => "Added $added new occurrences"]); } /** * Trim series: remove past occurrences before a cutoff date */ private function handleTrimRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); $cutoffDate = $INPUT->str('cutoff_date', date('Y-m-d')); $events = $this->getRecurringSeriesEvents($title, $namespace); $removed = 0; foreach ($events as $entry) { if ($entry['date'] < $cutoffDate) { // Remove this event from its file $data = json_decode(file_get_contents($entry['file']), true); if (!$data || !isset($data[$entry['date']])) continue; // Find and remove by matching title foreach ($data[$entry['date']] as $k => $evt) { if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { unset($data[$entry['date']][$k]); $data[$entry['date']] = array_values($data[$entry['date']]); $removed++; break; } } // Clean up empty dates if (empty($data[$entry['date']])) unset($data[$entry['date']]); if (empty($data)) { unlink($entry['file']); } else { file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); } } } $this->clearStatsCache(); echo json_encode(['success' => true, 'message' => "Removed $removed past occurrences before $cutoffDate"]); } /** * Pause series: mark all future occurrences as paused */ private function handlePauseRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); $today = date('Y-m-d'); $events = $this->getRecurringSeriesEvents($title, $namespace); $paused = 0; foreach ($events as $entry) { if ($entry['date'] >= $today) { $data = json_decode(file_get_contents($entry['file']), true); if (!$data || !isset($data[$entry['date']])) continue; foreach ($data[$entry['date']] as $k => &$evt) { if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { $evt['paused'] = true; $evt['title'] = 'โธ ' . preg_replace('/^โธ\s*/', '', $evt['title']); $paused++; break; } } unset($evt); file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); } } $this->clearStatsCache(); echo json_encode(['success' => true, 'message' => "Paused $paused future occurrences"]); } /** * Resume series: unmark paused occurrences */ private function handleResumeRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); // Search for both paused and non-paused versions $dataDir = DOKU_INC . 'data/meta/'; if ($namespace !== '') { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; $resumed = 0; $cleanTitle = preg_replace('/^โธ\s*/', '', $title); if (!is_dir($dataDir)) { echo json_encode(['success' => false, 'error' => 'Directory not found']); return; } foreach (glob($dataDir . '*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { foreach ($dayEvents as $k => &$evt) { $evtCleanTitle = preg_replace('/^โธ\s*/', '', $evt['title']); if (strtolower(trim($evtCleanTitle)) === strtolower(trim($cleanTitle)) && (!empty($evt['paused']) || strpos($evt['title'], 'โธ') === 0)) { $evt['paused'] = false; $evt['title'] = $cleanTitle; $resumed++; $modified = true; } } unset($evt); } unset($dayEvents); if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } $this->clearStatsCache(); echo json_encode(['success' => true, 'message' => "Resumed $resumed occurrences"]); } /** * Change start date: shift all occurrences by an offset */ private function handleChangeStartRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); $newStartDate = $INPUT->str('new_start_date'); if (empty($newStartDate)) { echo json_encode(['success' => false, 'error' => 'No start date provided']); return; } $events = $this->getRecurringSeriesEvents($title, $namespace); if (empty($events)) { echo json_encode(['success' => false, 'error' => 'Series not found']); return; } // Calculate offset from old first date to new first date $oldFirst = new DateTime($events[0]['date']); $newFirst = new DateTime($newStartDate); $offsetDays = (int)$oldFirst->diff($newFirst)->format('%r%a'); if ($offsetDays === 0) { echo json_encode(['success' => true, 'message' => 'Start date unchanged']); return; } $dataDir = DOKU_INC . 'data/meta/'; if ($namespace !== '') { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; // Collect all events to move $toMove = []; foreach ($events as $entry) { $oldDate = new DateTime($entry['date']); $newDate = clone $oldDate; $newDate->modify(($offsetDays > 0 ? '+' : '') . $offsetDays . ' days'); $toMove[] = [ 'oldDate' => $entry['date'], 'newDate' => $newDate->format('Y-m-d'), 'event' => $entry['event'], 'file' => $entry['file'] ]; } // Remove all from old positions foreach ($toMove as $move) { $data = json_decode(file_get_contents($move['file']), true); if (!$data || !isset($data[$move['oldDate']])) continue; foreach ($data[$move['oldDate']] as $k => $evt) { if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { unset($data[$move['oldDate']][$k]); $data[$move['oldDate']] = array_values($data[$move['oldDate']]); break; } } if (empty($data[$move['oldDate']])) unset($data[$move['oldDate']]); if (empty($data)) { unlink($move['file']); } else { file_put_contents($move['file'], json_encode($data, JSON_PRETTY_PRINT)); } } // Add to new positions $moved = 0; foreach ($toMove as $move) { list($year, $month) = explode('-', $move['newDate']); $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); $data = file_exists($file) ? json_decode(file_get_contents($file), true) : []; if (!is_array($data)) $data = []; if (!isset($data[$move['newDate']])) $data[$move['newDate']] = []; $data[$move['newDate']][] = $move['event']; file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); $moved++; } $dir = $offsetDays > 0 ? 'forward' : 'back'; $this->clearStatsCache(); echo json_encode(['success' => true, 'message' => "Shifted $moved occurrences $dir by " . abs($offsetDays) . " days"]); } /** * Change pattern: re-space all future events with a new interval */ private function handleChangePatternRecurring() { global $INPUT; $title = $INPUT->str('title'); $namespace = $INPUT->str('namespace'); $newIntervalDays = $INPUT->int('interval_days', 7); $events = $this->getRecurringSeriesEvents($title, $namespace); $today = date('Y-m-d'); // Split into past and future $pastEvents = []; $futureEvents = []; foreach ($events as $e) { if ($e['date'] < $today) { $pastEvents[] = $e; } else { $futureEvents[] = $e; } } if (empty($futureEvents)) { echo json_encode(['success' => false, 'error' => 'No future occurrences to respace']); return; } $dataDir = DOKU_INC . 'data/meta/'; if ($namespace !== '') { $dataDir .= str_replace(':', '/', $namespace) . '/'; } $dataDir .= 'calendar/'; // Use first future event as anchor $anchorDate = new DateTime($futureEvents[0]['date']); // Remove all future events from files foreach ($futureEvents as $entry) { $data = json_decode(file_get_contents($entry['file']), true); if (!$data || !isset($data[$entry['date']])) continue; foreach ($data[$entry['date']] as $k => $evt) { if (strtolower(trim($evt['title'])) === strtolower(trim($title))) { unset($data[$entry['date']][$k]); $data[$entry['date']] = array_values($data[$entry['date']]); break; } } if (empty($data[$entry['date']])) unset($data[$entry['date']]); if (empty($data)) { unlink($entry['file']); } else { file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); } } // Re-create with new spacing $template = $futureEvents[0]['event']; $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($title . $namespace); $count = count($futureEvents); $created = 0; for ($i = 0; $i < $count; $i++) { $newDate = clone $anchorDate; $newDate->modify('+' . ($i * $newIntervalDays) . ' days'); $dateKey = $newDate->format('Y-m-d'); list($year, $month) = explode('-', $dateKey); $file = $dataDir . sprintf('%04d-%02d.json', $year, $month); $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; if (!is_array($fileData)) $fileData = []; if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; $newEvent = $template; $newEvent['id'] = $baseId . '-respace-' . $i; $newEvent['recurring'] = true; $newEvent['recurringId'] = $baseId; $fileData[$dateKey][] = $newEvent; file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); $created++; } $this->clearStatsCache(); $patternName = $this->intervalToPattern($newIntervalDays); echo json_encode(['success' => true, 'message' => "Respaced $created future occurrences to $patternName ($newIntervalDays days)"]); } private function intervalToPattern($days) { if ($days == 1) return 'Daily'; if ($days == 7) return 'Weekly'; if ($days == 14) return 'Bi-weekly'; if ($days >= 28 && $days <= 31) return 'Monthly'; if ($days >= 89 && $days <= 93) return 'Quarterly'; if ($days >= 363 && $days <= 368) return 'Yearly'; return "Every $days days"; } private function getEventsByNamespace() { $dataDir = DOKU_INC . 'data/meta/'; $result = []; // Check root calendar directory first (blank/default namespace) $rootCalendarDir = $dataDir . 'calendar'; if (is_dir($rootCalendarDir)) { $hasFiles = false; $events = []; foreach (glob($rootCalendarDir . '/*.json') as $file) { $hasFiles = true; $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $eventList) { foreach ($eventList as $event) { $events[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'month' => $month ]; } } } // Add if it has JSON files (even if empty) if ($hasFiles) { $result[''] = ['events' => $events]; } } // Recursively scan all namespace directories including sub-namespaces $this->scanNamespaceRecursive($dataDir, '', $result); // Sort namespaces, but keep '' (default) first uksort($result, function($a, $b) { if ($a === '') return -1; if ($b === '') return 1; return strcmp($a, $b); }); return $result; } private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) { foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { $dirName = basename($nsDir); // Skip the root 'calendar' dir if ($dirName === 'calendar' && empty($parentNamespace)) continue; // Build namespace path $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName; // Check for calendar directory $calendarDir = $nsDir . '/calendar'; if (is_dir($calendarDir)) { $hasFiles = false; $events = []; // Scan all calendar files foreach (glob($calendarDir . '/*.json') as $file) { $hasFiles = true; $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $eventList) { foreach ($eventList as $event) { $events[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'month' => $month ]; } } } // Add namespace if it has JSON files (even if empty) if ($hasFiles) { $result[$namespace] = ['events' => $events]; } } // Recursively scan sub-directories $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result); } } private function getAllNamespaces() { $dataDir = DOKU_INC . 'data/meta/'; $namespaces = []; // Check root calendar directory first $rootCalendarDir = $dataDir . 'calendar'; if (is_dir($rootCalendarDir)) { $namespaces[] = ''; // Blank/default namespace } // Check all other namespace directories foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); // Skip the root 'calendar' dir (already added as '') if ($namespace === 'calendar') continue; $calendarDir = $nsDir . '/calendar'; if (is_dir($calendarDir)) { $namespaces[] = $namespace; } } return $namespaces; } private function searchEvents($search, $filterNamespace) { $dataDir = DOKU_INC . 'data/meta/'; $results = []; $search = strtolower(trim($search)); foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); $calendarDir = $nsDir . '/calendar'; if (!is_dir($calendarDir)) continue; if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue; foreach (glob($calendarDir . '/*.json') as $file) { $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $events) { foreach ($events as $event) { if ($search === '' || strpos(strtolower($event['title']), $search) !== false) { $results[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'namespace' => $event['namespace'] ?? '', 'month' => $month ]; } } } } } return $results; } private function deleteRecurringSeries() { global $INPUT; $eventTitle = $INPUT->str('event_title'); $namespace = $INPUT->str('namespace'); // Collect ALL calendar directories $dataDir = DOKU_INC . 'data/meta/'; $calendarDirs = []; if (is_dir($dataDir . 'calendar')) { $calendarDirs[] = $dataDir . 'calendar'; } $this->findCalendarDirs($dataDir, $calendarDirs); $count = 0; foreach ($calendarDirs as $calDir) { foreach (glob($calDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => $events) { $filtered = []; foreach ($events as $event) { $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; // Match by title AND namespace field if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle)) && strtolower(trim($eventNs)) === strtolower(trim($namespace))) { $count++; $modified = true; } else { $filtered[] = $event; } } $data[$dateKey] = $filtered; } if ($modified) { foreach ($data as $dk => $evts) { if (empty($evts)) unset($data[$dk]); } if (empty($data)) { unlink($file); } else { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } } } $this->clearStatsCache(); $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage'); } private function editRecurringSeries() { global $INPUT; $oldTitle = $INPUT->str('old_title'); $oldNamespace = $INPUT->str('old_namespace'); $newTitle = $INPUT->str('new_title'); $startTime = $INPUT->str('start_time'); $endTime = $INPUT->str('end_time'); $interval = $INPUT->int('interval', 0); $newNamespace = $INPUT->str('new_namespace'); // Use old namespace if new namespace is empty (keep current) if (empty($newNamespace) && !isset($_POST['new_namespace'])) { $newNamespace = $oldNamespace; } // Collect ALL calendar directories to search $dataDir = DOKU_INC . 'data/meta/'; $calendarDirs = []; // Root calendar dir if (is_dir($dataDir . 'calendar')) { $calendarDirs[] = $dataDir . 'calendar'; } // All namespace dirs $this->findCalendarDirs($dataDir, $calendarDirs); $count = 0; // Pass 1: Rename title, update time, update namespace field in ALL matching events foreach ($calendarDirs as $calDir) { if (is_string($calDir)) { $dir = $calDir; } else { $dir = $calDir['dir']; } foreach (glob($dir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { if (!is_array($dayEvents)) continue; foreach ($dayEvents as $key => &$event) { // Match by old title (case-insensitive) AND namespace field $eventNs = isset($event['namespace']) ? $event['namespace'] : ''; if (strtolower(trim($event['title'])) !== strtolower(trim($oldTitle))) continue; if (strtolower(trim($eventNs)) !== strtolower(trim($oldNamespace))) continue; // Update title $event['title'] = $newTitle; // Update start time if provided if (!empty($startTime)) { $event['time'] = $startTime; } // Update end time if provided if (!empty($endTime)) { $event['endTime'] = $endTime; } // Update namespace field $event['namespace'] = $newNamespace; $count++; $modified = true; } unset($event); } unset($dayEvents); if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } } // Pass 2: Handle interval changes (respace events from first date) if ($interval > 0 && $count > 0) { // Use getRecurringSeriesEvents to find all events with the NEW title $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace); if (count($allEvents) > 1) { $firstDate = new DateTime($allEvents[0]['date']); // Remove all except first, then re-create with new spacing for ($i = 1; $i < count($allEvents); $i++) { $entry = $allEvents[$i]; $data = json_decode(file_get_contents($entry['file']), true); if (!$data || !isset($data[$entry['date']])) continue; foreach ($data[$entry['date']] as $k => $evt) { if (strtolower(trim($evt['title'])) === strtolower(trim($newTitle))) { unset($data[$entry['date']][$k]); $data[$entry['date']] = array_values($data[$entry['date']]); break; } } if (empty($data[$entry['date']])) unset($data[$entry['date']]); if (empty($data)) { unlink($entry['file']); } else { file_put_contents($entry['file'], json_encode($data, JSON_PRETTY_PRINT)); } } // Re-create with new interval $template = $allEvents[0]['event']; $targetDir = ($newNamespace === '') ? DOKU_INC . 'data/meta/calendar' : DOKU_INC . 'data/meta/' . str_replace(':', '/', $newNamespace) . '/calendar'; if (!is_dir($targetDir)) mkdir($targetDir, 0755, true); $baseId = isset($template['recurringId']) ? $template['recurringId'] : md5($newTitle . $newNamespace); for ($i = 1; $i < count($allEvents); $i++) { $newDate = clone $firstDate; $newDate->modify('+' . ($i * $interval) . ' days'); $dateKey = $newDate->format('Y-m-d'); list($year, $month) = explode('-', $dateKey); $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month); $fileData = file_exists($file) ? json_decode(file_get_contents($file), true) : []; if (!is_array($fileData)) $fileData = []; if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; $newEvent = $template; $newEvent['id'] = $baseId . '-respace-' . $i; $fileData[$dateKey][] = $newEvent; file_put_contents($file, json_encode($fileData, JSON_PRETTY_PRINT)); } } } $changes = []; if ($oldTitle !== $newTitle) $changes[] = "title"; if (!empty($startTime) || !empty($endTime)) $changes[] = "time"; if ($interval > 0) $changes[] = "interval"; if ($newNamespace !== $oldNamespace) $changes[] = "namespace"; $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : ""; $this->clearStatsCache(); $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage'); } /** * Find all calendar directories recursively */ private function findCalendarDirs($baseDir, &$dirs) { foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { $name = basename($nsDir); if ($name === 'calendar') continue; // Skip root calendar (added separately) $calDir = $nsDir . '/calendar'; if (is_dir($calDir)) { $dirs[] = $calDir; } // Recurse $this->findCalendarDirs($nsDir . '/', $dirs); } } private function moveEvents() { global $INPUT; $events = $INPUT->arr('events'); $targetNamespace = $INPUT->str('target_namespace'); if (empty($events)) { $this->redirect('No events selected', 'error', 'manage'); } $moved = 0; foreach ($events as $eventData) { list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine old file path if ($namespace === '') { $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($oldFile)) continue; $oldData = json_decode(file_get_contents($oldFile), true); if (!$oldData) continue; // Find and remove event from old file $event = null; if (isset($oldData[$date])) { foreach ($oldData[$date] as $key => $evt) { if ($evt['id'] === $id) { $event = $evt; unset($oldData[$date][$key]); $oldData[$date] = array_values($oldData[$date]); break; } } // Remove empty date arrays if (empty($oldData[$date])) { unset($oldData[$date]); } } if (!$event) continue; // Save old file file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); // Update event namespace $event['namespace'] = $targetNamespace; // Determine new file path if ($targetNamespace === '') { $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; $newDir = dirname($newFile); } else { $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; $newDir = dirname($newFile); } if (!is_dir($newDir)) { mkdir($newDir, 0755, true); } $newData = []; if (file_exists($newFile)) { $newData = json_decode(file_get_contents($newFile), true) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); $moved++; } $displayTarget = $targetNamespace ?: '(default)'; $this->clearStatsCache(); $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage'); } private function moveSingleEvent() { global $INPUT; $eventData = $INPUT->str('event'); $targetNamespace = $INPUT->str('target_namespace'); list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine old file path if ($namespace === '') { $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($oldFile)) { $this->redirect('Event file not found', 'error', 'manage'); } $oldData = json_decode(file_get_contents($oldFile), true); if (!$oldData) { $this->redirect('Could not read event file', 'error', 'manage'); } // Find and remove event from old file $event = null; if (isset($oldData[$date])) { foreach ($oldData[$date] as $key => $evt) { if ($evt['id'] === $id) { $event = $evt; unset($oldData[$date][$key]); $oldData[$date] = array_values($oldData[$date]); break; } } // Remove empty date arrays if (empty($oldData[$date])) { unset($oldData[$date]); } } if (!$event) { $this->redirect('Event not found', 'error', 'manage'); } // Save old file (or delete if empty) if (empty($oldData)) { unlink($oldFile); } else { file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); } // Update event namespace $event['namespace'] = $targetNamespace; // Determine new file path if ($targetNamespace === '') { $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; $newDir = dirname($newFile); } else { $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; $newDir = dirname($newFile); } if (!is_dir($newDir)) { mkdir($newDir, 0755, true); } $newData = []; if (file_exists($newFile)) { $newData = json_decode(file_get_contents($newFile), true) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); $displayTarget = $targetNamespace ?: '(default)'; $this->clearStatsCache(); $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage'); } private function createNamespace() { global $INPUT; $namespaceName = $INPUT->str('namespace_name'); // Validate namespace name if (empty($namespaceName)) { $this->redirect('Namespace name cannot be empty', 'error', 'manage'); } if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) { $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); } // Convert namespace to directory path $namespacePath = str_replace(':', '/', $namespaceName); $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; // Check if already exists if (is_dir($calendarDir)) { // Check if it has any JSON files $hasFiles = !empty(glob($calendarDir . '/*.json')); if ($hasFiles) { $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage'); } // If directory exists but empty, continue to create placeholder } // Create the directory if (!is_dir($calendarDir)) { if (!mkdir($calendarDir, 0755, true)) { $this->redirect("Failed to create namespace directory", 'error', 'manage'); } } // Create a placeholder JSON file with an empty structure for current month // This ensures the namespace appears in the list immediately $currentMonth = date('Y-m'); $placeholderFile = $calendarDir . '/' . $currentMonth . '.json'; if (!file_exists($placeholderFile)) { file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT)); } $this->redirect("Created namespace: $namespaceName", 'success', 'manage'); } private function deleteNamespace() { global $INPUT; $namespace = $INPUT->str('namespace'); // Validate namespace name to prevent path traversal if ($namespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $namespace)) { $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); return; } // Additional safety: ensure no path traversal sequences if (strpos($namespace, '..') !== false || strpos($namespace, '/') !== false || strpos($namespace, '\\') !== false) { $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); return; } // Convert namespace to directory path (e.g., "work:projects" โ "work/projects") $namespacePath = str_replace(':', '/', $namespace); // Determine calendar directory if ($namespace === '') { $calendarDir = DOKU_INC . 'data/meta/calendar'; $namespaceDir = null; // Don't delete root } else { $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; $namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath; } // Check if directory exists if (!is_dir($calendarDir)) { // Maybe it was never created or already deleted $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage'); return; } $filesDeleted = 0; $eventsDeleted = 0; // Delete all calendar JSON files (including empty ones) foreach (glob($calendarDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if ($data) { foreach ($data as $events) { $eventsDeleted += count($events); } } unlink($file); $filesDeleted++; } // Delete any other files in calendar directory foreach (glob($calendarDir . '/*') as $file) { if (is_file($file)) { unlink($file); } } // Remove the calendar directory if ($namespace !== '') { @rmdir($calendarDir); // Try to remove parent directories if they're empty // This handles nested namespaces like work:projects:alpha $currentDir = dirname($calendarDir); $metaDir = DOKU_INC . 'data/meta'; while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { if (is_dir($currentDir)) { // Check if directory is empty $contents = scandir($currentDir); $isEmpty = count($contents) === 2; // Only . and .. if ($isEmpty) { @rmdir($currentDir); $currentDir = dirname($currentDir); } else { break; // Directory not empty, stop } } else { break; } } } $displayName = $namespace ?: '(default)'; $this->clearStatsCache(); $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage'); } private function renameNamespace() { global $INPUT; $oldNamespace = $INPUT->str('old_namespace'); $newNamespace = $INPUT->str('new_namespace'); // Validate namespace names to prevent path traversal if ($oldNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $oldNamespace)) { $this->redirect('Invalid old namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); return; } if ($newNamespace !== '' && !preg_match('/^[a-zA-Z0-9_:-]+$/', $newNamespace)) { $this->redirect('Invalid new namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); return; } // Additional safety: ensure no path traversal sequences if (strpos($oldNamespace, '..') !== false || strpos($oldNamespace, '/') !== false || strpos($oldNamespace, '\\') !== false || strpos($newNamespace, '..') !== false || strpos($newNamespace, '/') !== false || strpos($newNamespace, '\\') !== false) { $this->redirect('Invalid namespace: path traversal not allowed', 'error', 'manage'); return; } // Validate new namespace name if ($newNamespace === '') { $this->redirect("Cannot rename to empty namespace", 'error', 'manage'); return; } // Convert namespaces to directory paths $oldPath = str_replace(':', '/', $oldNamespace); $newPath = str_replace(':', '/', $newNamespace); // Determine source and destination directories if ($oldNamespace === '') { $sourceDir = DOKU_INC . 'data/meta/calendar'; } else { $sourceDir = DOKU_INC . 'data/meta/' . $oldPath . '/calendar'; } if ($newNamespace === '') { $targetDir = DOKU_INC . 'data/meta/calendar'; } else { $targetDir = DOKU_INC . 'data/meta/' . $newPath . '/calendar'; } // Check if source exists if (!is_dir($sourceDir)) { $this->redirect("Source namespace not found: $oldNamespace", 'error', 'manage'); return; } // Check if target already exists if (is_dir($targetDir)) { $this->redirect("Target namespace already exists: $newNamespace", 'error', 'manage'); return; } // Create target directory if (!file_exists(dirname($targetDir))) { mkdir(dirname($targetDir), 0755, true); } // Rename directory if (!rename($sourceDir, $targetDir)) { $this->redirect("Failed to rename namespace", 'error', 'manage'); return; } // Update event namespace field in all JSON files $eventsUpdated = 0; foreach (glob($targetDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if ($data) { foreach ($data as $date => &$events) { foreach ($events as &$event) { if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) { $event['namespace'] = $newNamespace; $eventsUpdated++; } } } file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } // Clean up old directory structure if empty if ($oldNamespace !== '') { $currentDir = dirname($sourceDir); $metaDir = DOKU_INC . 'data/meta'; while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { if (is_dir($currentDir)) { $contents = scandir($currentDir); $isEmpty = count($contents) === 2; // Only . and .. if ($isEmpty) { @rmdir($currentDir); $currentDir = dirname($currentDir); } else { break; } } else { break; } } } $this->clearStatsCache(); $this->redirect("Renamed namespace from '$oldNamespace' to '$newNamespace' ($eventsUpdated events updated)", 'success', 'manage'); } private function deleteSelectedEvents() { global $INPUT; $events = $INPUT->arr('events'); if (empty($events)) { $this->redirect('No events selected', 'error', 'manage'); } $deletedCount = 0; foreach ($events as $eventData) { list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine file path if ($namespace === '') { $file = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($file)) continue; $data = json_decode(file_get_contents($file), true); if (!$data) continue; // Find and remove event if (isset($data[$date])) { foreach ($data[$date] as $key => $evt) { if ($evt['id'] === $id) { unset($data[$date][$key]); $data[$date] = array_values($data[$date]); $deletedCount++; break; } } // Remove empty date arrays if (empty($data[$date])) { unset($data[$date]); } // Save file file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } $this->clearStatsCache(); $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage'); } /** * Clear the event statistics cache so counts refresh after mutations */ private function saveImportantNamespaces() { global $INPUT; $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; $config = []; if (file_exists($configFile)) { $config = include $configFile; } $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important'); $content = "redirect('Important namespaces saved', 'success', 'manage'); } else { $this->redirect('Error: Could not save configuration', 'error', 'manage'); } } private function clearStatsCache() { $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; if (file_exists($cacheFile)) { unlink($cacheFile); } } private function getCronStatus() { // Try to read root's crontab first, then current user $output = []; exec('sudo crontab -l 2>/dev/null', $output); // If sudo doesn't work, try current user if (empty($output)) { exec('crontab -l 2>/dev/null', $output); } // Also check system crontab files if (empty($output)) { $cronFiles = [ '/etc/crontab', '/etc/cron.d/calendar', '/var/spool/cron/root', '/var/spool/cron/crontabs/root' ]; foreach ($cronFiles as $file) { if (file_exists($file) && is_readable($file)) { $content = file_get_contents($file); $output = explode("\n", $content); break; } } } // Look for sync_outlook.php in the cron entries foreach ($output as $line) { $line = trim($line); // Skip empty lines and comments if (empty($line) || $line[0] === '#') continue; // Check if line contains sync_outlook.php if (strpos($line, 'sync_outlook.php') !== false) { // Parse cron expression // Format: minute hour day month weekday [user] command $parts = preg_split('/\s+/', $line, 7); if (count($parts) >= 5) { // Determine if this has a user field (system crontab format) $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5])); $offset = $hasUser ? 1 : 0; $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]); return [ 'active' => true, 'frequency' => $frequency, 'expression' => implode(' ', array_slice($parts, 0, 5)), 'full_line' => $line ]; } } } return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => '']; } private function parseCronExpression($minute, $hour, $day, $month, $weekday) { // Parse minute field if ($minute === '*') { return 'Runs every minute'; } elseif (strpos($minute, '*/') === 0) { $interval = substr($minute, 2); if ($interval == 1) { return 'Runs every minute'; } elseif ($interval == 5) { return 'Runs every 5 minutes'; } elseif ($interval == 8) { return 'Runs every 8 minutes'; } elseif ($interval == 10) { return 'Runs every 10 minutes'; } elseif ($interval == 15) { return 'Runs every 15 minutes'; } elseif ($interval == 30) { return 'Runs every 30 minutes'; } else { return "Runs every $interval minutes"; } } // Parse hour field if ($hour === '*' && $minute !== '*') { return 'Runs hourly'; } elseif (strpos($hour, '*/') === 0 && $minute !== '*') { $interval = substr($hour, 2); if ($interval == 1) { return 'Runs every hour'; } else { return "Runs every $interval hours"; } } // Parse day field if ($day === '*' && $hour !== '*' && $minute !== '*') { return 'Runs daily'; } // Default return 'Custom schedule'; } private function runSync() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php'; $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; // Remove any existing abort flag if (file_exists($abortFile)) { @unlink($abortFile); } if (!file_exists($syncScript)) { echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]); exit; } // Change to plugin directory $pluginDir = DOKU_PLUGIN . 'calendar'; $logFile = $pluginDir . '/sync.log'; // Ensure log file exists and is writable if (!file_exists($logFile)) { @touch($logFile); @chmod($logFile, 0666); } // Try to log the execution (but don't fail if we can't) if (is_writable($logFile)) { $tz = new DateTimeZone('America/Los_Angeles'); $now = new DateTime('now', $tz); $timestamp = $now->format('Y-m-d H:i:s'); @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND); } // Find PHP binary - try multiple methods $phpPath = $this->findPhpBinary(); // Build command $command = sprintf( 'cd %s && %s %s 2>&1', escapeshellarg($pluginDir), $phpPath, escapeshellarg(basename($syncScript)) ); // Execute and capture output $output = []; $returnCode = 0; exec($command, $output, $returnCode); // Check if sync completed $lastLines = array_slice($output, -5); $completed = false; foreach ($lastLines as $line) { if (strpos($line, 'Sync Complete') !== false || strpos($line, 'Created:') !== false) { $completed = true; break; } } if ($returnCode === 0 && $completed) { echo json_encode([ 'success' => true, 'message' => 'Sync completed successfully! Check log below.' ]); } elseif ($returnCode === 0) { echo json_encode([ 'success' => true, 'message' => 'Sync started. Check log below for progress.' ]); } else { // Include output for debugging $errorMsg = 'Sync failed with error code: ' . $returnCode; if (!empty($output)) { $errorMsg .= ' | ' . implode(' | ', array_slice($output, -3)); } echo json_encode([ 'success' => false, 'message' => $errorMsg ]); } exit; } } private function stopSync() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; // Create abort flag file if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) { echo json_encode([ 'success' => true, 'message' => 'Stop signal sent to sync process' ]); } else { echo json_encode([ 'success' => false, 'message' => 'Failed to create abort flag' ]); } exit; } } private function uploadUpdate() { if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) { $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update'); return; } $uploadedFile = $_FILES['plugin_zip']['tmp_name']; $pluginDir = DOKU_PLUGIN . 'calendar/'; $backupFirst = isset($_POST['backup_first']); // Check if plugin directory is writable if (!is_writable($pluginDir)) { $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update'); return; } // Check if parent directory is writable (for backup and temp files) if (!is_writable(DOKU_PLUGIN)) { $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update'); return; } // Verify it's a ZIP file $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $uploadedFile); finfo_close($finfo); if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') { $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update'); return; } // Create backup if requested if ($backupFirst) { // Get current version $pluginInfo = $pluginDir . 'plugin.info.txt'; $version = 'unknown'; if (file_exists($pluginInfo)) { $info = confToHash($pluginInfo); $version = $info['version'] ?? ($info['date'] ?? 'unknown'); } $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip'; $backupPath = DOKU_PLUGIN . $backupName; try { $zip = new ZipArchive(); if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); $zip->close(); // Verify backup was created and has content if (!file_exists($backupPath)) { $this->redirect('Backup file was not created', 'error', 'update'); return; } $backupSize = filesize($backupPath); if ($backupSize < 1000) { // Backup should be at least 1KB @unlink($backupPath); $this->redirect('Backup file is too small (' . $backupSize . ' bytes). Only ' . $fileCount . ' files were added. Backup aborted.', 'error', 'update'); return; } if ($fileCount < 10) { // Should have at least 10 files @unlink($backupPath); $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup aborted.', 'error', 'update'); return; } } else { $this->redirect('Failed to create backup ZIP file', 'error', 'update'); return; } } catch (Exception $e) { if (file_exists($backupPath)) { @unlink($backupPath); } $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); return; } } // Extract uploaded ZIP $zip = new ZipArchive(); if ($zip->open($uploadedFile) !== TRUE) { $this->redirect('Failed to open ZIP file', 'error', 'update'); return; } // Check if ZIP contains calendar folder $hasCalendarFolder = false; for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (strpos($filename, 'calendar/') === 0) { $hasCalendarFolder = true; break; } } // Extract to temp directory first $tempDir = DOKU_PLUGIN . 'calendar_update_temp/'; if (is_dir($tempDir)) { $this->deleteDirectory($tempDir); } mkdir($tempDir); $zip->extractTo($tempDir); $zip->close(); // Determine source directory if ($hasCalendarFolder) { $sourceDir = $tempDir . 'calendar/'; } else { $sourceDir = $tempDir; } // Preserve configuration files $preserveFiles = ['sync_config.php', 'sync_state.json', 'sync.log']; $preserved = []; foreach ($preserveFiles as $file) { $oldFile = $pluginDir . $file; if (file_exists($oldFile)) { $preserved[$file] = file_get_contents($oldFile); } } // Delete old plugin files (except data files) $this->deleteDirectoryContents($pluginDir, $preserveFiles); // Copy new files $this->recursiveCopy($sourceDir, $pluginDir); // Restore preserved files foreach ($preserved as $file => $content) { file_put_contents($pluginDir . $file, $content); } // Update version and date in plugin.info.txt $pluginInfo = $pluginDir . 'plugin.info.txt'; if (file_exists($pluginInfo)) { $info = confToHash($pluginInfo); // Get new version from uploaded plugin $newVersion = $info['version'] ?? 'unknown'; // Update date to current $info['date'] = date('Y-m-d'); // Write updated info back $lines = []; foreach ($info as $key => $value) { $lines[] = str_pad($key, 8) . ' ' . $value; } file_put_contents($pluginInfo, implode("\n", $lines) . "\n"); } // Cleanup temp directory $this->deleteDirectory($tempDir); $message = 'Plugin updated successfully!'; if ($backupFirst) { $message .= ' Backup saved as: ' . $backupName; } $this->redirect($message, 'success', 'update'); } private function deleteBackup() { global $INPUT; $filename = $INPUT->str('backup_file'); if (empty($filename)) { $this->redirect('No backup file specified', 'error', 'update'); return; } // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { $this->redirect('Invalid backup filename', 'error', 'update'); return; } $backupPath = DOKU_PLUGIN . $filename; if (!file_exists($backupPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } if (@unlink($backupPath)) { $this->redirect('Backup deleted: ' . $filename, 'success', 'update'); } else { $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update'); } } private function renameBackup() { global $INPUT; $oldName = $INPUT->str('old_name'); $newName = $INPUT->str('new_name'); if (empty($oldName) || empty($newName)) { $this->redirect('Missing filename(s)', 'error', 'update'); return; } // Security: validate filenames if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) { $this->redirect('Invalid filename format', 'error', 'update'); return; } $oldPath = DOKU_PLUGIN . $oldName; $newPath = DOKU_PLUGIN . $newName; if (!file_exists($oldPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } if (file_exists($newPath)) { $this->redirect('A file with the new name already exists', 'error', 'update'); return; } if (@rename($oldPath, $newPath)) { $this->redirect('Backup renamed: ' . $oldName . ' โ ' . $newName, 'success', 'update'); } else { $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update'); } } private function restoreBackup() { global $INPUT; $filename = $INPUT->str('backup_file'); if (empty($filename)) { $this->redirect('No backup file specified', 'error', 'update'); return; } // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { $this->redirect('Invalid backup filename', 'error', 'update'); return; } $backupPath = DOKU_PLUGIN . $filename; $pluginDir = DOKU_PLUGIN . 'calendar/'; if (!file_exists($backupPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } // Check if plugin directory is writable if (!is_writable($pluginDir)) { $this->redirect('Plugin directory is not writable. Please check permissions.', 'error', 'update'); return; } // Extract backup to temp directory $tempDir = DOKU_PLUGIN . 'calendar_restore_temp/'; if (is_dir($tempDir)) { $this->deleteDirectory($tempDir); } mkdir($tempDir); $zip = new ZipArchive(); if ($zip->open($backupPath) !== TRUE) { $this->redirect('Failed to open backup ZIP file', 'error', 'update'); return; } $zip->extractTo($tempDir); $zip->close(); // The backup contains a "calendar/" folder $sourceDir = $tempDir . 'calendar/'; if (!is_dir($sourceDir)) { $this->deleteDirectory($tempDir); $this->redirect('Invalid backup structure', 'error', 'update'); return; } // Delete current plugin directory contents $this->deleteDirectoryContents($pluginDir, []); // Copy backup files to plugin directory $this->recursiveCopy($sourceDir, $pluginDir); // Cleanup temp directory $this->deleteDirectory($tempDir); $this->redirect('Plugin restored from backup: ' . $filename, 'success', 'update'); } private function createManualBackup() { $pluginDir = DOKU_PLUGIN . 'calendar/'; // Check if plugin directory is readable if (!is_readable($pluginDir)) { $this->redirect('Plugin directory is not readable. Please check permissions.', 'error', 'update'); return; } // Check if parent directory is writable (for saving backup) if (!is_writable(DOKU_PLUGIN)) { $this->redirect('Plugin parent directory is not writable. Cannot save backup.', 'error', 'update'); return; } // Get current version $pluginInfo = $pluginDir . 'plugin.info.txt'; $version = 'unknown'; if (file_exists($pluginInfo)) { $info = confToHash($pluginInfo); $version = $info['version'] ?? ($info['date'] ?? 'unknown'); } $backupName = 'calendar.backup.v' . $version . '.manual.' . date('Y-m-d_H-i-s') . '.zip'; $backupPath = DOKU_PLUGIN . $backupName; try { $zip = new ZipArchive(); if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { $fileCount = $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); $zip->close(); // Verify backup was created and has content if (!file_exists($backupPath)) { $this->redirect('Backup file was not created', 'error', 'update'); return; } $backupSize = filesize($backupPath); if ($backupSize < 1000) { // Backup should be at least 1KB @unlink($backupPath); $this->redirect('Backup file is too small (' . $this->formatBytes($backupSize) . '). Only ' . $fileCount . ' files were added. Backup failed.', 'error', 'update'); return; } if ($fileCount < 10) { // Should have at least 10 files @unlink($backupPath); $this->redirect('Backup incomplete: Only ' . $fileCount . ' files were added (expected 30+). Backup failed.', 'error', 'update'); return; } // Success! $this->redirect('โ Manual backup created successfully: ' . $backupName . ' (' . $this->formatBytes($backupSize) . ', ' . $fileCount . ' files)', 'success', 'update'); } else { $this->redirect('Failed to create backup ZIP file', 'error', 'update'); return; } } catch (Exception $e) { if (file_exists($backupPath)) { @unlink($backupPath); } $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); return; } } private function addDirectoryToZip($zip, $dir, $zipPath = '') { $fileCount = 0; $errors = []; // Ensure dir has trailing slash $dir = rtrim($dir, '/') . '/'; if (!is_dir($dir)) { throw new Exception("Directory does not exist: $dir"); } if (!is_readable($dir)) { throw new Exception("Directory is not readable: $dir"); } try { // First, add all directories to preserve structure (including empty ones) $dirs = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST // Process directories before their contents ); foreach ($dirs as $item) { $itemPath = $item->getRealPath(); if (!$itemPath) continue; // Calculate relative path from the source directory $relativePath = $zipPath . substr($itemPath, strlen($dir)); if ($item->isDir()) { // Add directory to ZIP (preserves empty directories and structure) $dirInZip = rtrim($relativePath, '/') . '/'; $zip->addEmptyDir($dirInZip); } else { // Add file to ZIP if (is_readable($itemPath)) { if ($zip->addFile($itemPath, $relativePath)) { $fileCount++; } else { $errors[] = "Failed to add: " . basename($itemPath); } } else { $errors[] = "Cannot read: " . basename($itemPath); } } } // Log any errors but don't fail if we got most files if (!empty($errors) && count($errors) < 5) { foreach ($errors as $error) { error_log('Calendar plugin backup warning: ' . $error); } } // If too many errors, fail if (count($errors) > 5) { throw new Exception("Too many errors adding files to backup: " . implode(', ', array_slice($errors, 0, 5))); } } catch (Exception $e) { error_log('Calendar plugin backup error: ' . $e->getMessage()); throw $e; } return $fileCount; } private function deleteDirectory($dir) { if (!is_dir($dir)) return; try { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($files as $file) { if ($file->isDir()) { @rmdir($file->getRealPath()); } else { @unlink($file->getRealPath()); } } @rmdir($dir); } catch (Exception $e) { error_log('Calendar plugin delete directory error: ' . $e->getMessage()); } } private function deleteDirectoryContents($dir, $preserve = []) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; if (in_array($item, $preserve)) continue; $path = $dir . $item; if (is_dir($path)) { $this->deleteDirectory($path); } else { unlink($path); } } } private function recursiveCopy($src, $dst) { if (!is_dir($src)) { return false; } $dir = opendir($src); if (!$dir) { return false; } // Create destination directory with proper permissions (0755) if (!is_dir($dst)) { mkdir($dst, 0755, true); } while (($file = readdir($dir)) !== false) { if ($file !== '.' && $file !== '..') { $srcPath = $src . '/' . $file; $dstPath = $dst . '/' . $file; if (is_dir($srcPath)) { // Recursively copy subdirectory $this->recursiveCopy($srcPath, $dstPath); } else { // Copy file and preserve permissions if (copy($srcPath, $dstPath)) { // Try to preserve file permissions from source, fallback to 0644 $perms = @fileperms($srcPath); if ($perms !== false) { @chmod($dstPath, $perms); } else { @chmod($dstPath, 0644); } } } } } closedir($dir); return true; } private function formatBytes($bytes) { if ($bytes >= 1073741824) { return number_format($bytes / 1073741824, 2) . ' GB'; } elseif ($bytes >= 1048576) { return number_format($bytes / 1048576, 2) . ' MB'; } elseif ($bytes >= 1024) { return number_format($bytes / 1024, 2) . ' KB'; } else { return $bytes . ' bytes'; } } private function findPhpBinary() { // Try PHP_BINARY constant first (most reliable if available) if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) { return escapeshellarg(PHP_BINARY); } // Try common PHP binary locations $possiblePaths = [ '/usr/bin/php', '/usr/bin/php8.1', '/usr/bin/php8.2', '/usr/bin/php8.3', '/usr/bin/php7.4', '/usr/local/bin/php', 'php' // Last resort - rely on PATH ]; foreach ($possiblePaths as $path) { // Test if this PHP binary works $testOutput = []; $testReturn = 0; exec($path . ' -v 2>&1', $testOutput, $testReturn); if ($testReturn === 0) { return ($path === 'php') ? 'php' : escapeshellarg($path); } } // Fallback to 'php' and hope it's in PATH return 'php'; } private function redirect($message, $type = 'success', $tab = null) { $url = '?do=admin&page=calendar'; if ($tab) { $url .= '&tab=' . $tab; } $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type; header('Location: ' . $url); exit; } private function getLog() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $logFile = DOKU_PLUGIN . 'calendar/sync.log'; $log = ''; if (file_exists($logFile)) { // Get last 500 lines $lines = file($logFile); if ($lines !== false) { $lines = array_slice($lines, -500); $log = implode('', $lines); } } else { $log = "No log file found. Sync hasn't run yet."; } echo json_encode(['log' => $log]); exit; } } private function exportConfig() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); try { $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; if (!file_exists($configFile)) { echo json_encode([ 'success' => false, 'message' => 'Config file not found' ]); exit; } // Read config file $configContent = file_get_contents($configFile); // Generate encryption key from DokuWiki secret $key = $this->getEncryptionKey(); // Encrypt config $encrypted = $this->encryptData($configContent, $key); echo json_encode([ 'success' => true, 'encrypted' => $encrypted, 'message' => 'Config exported successfully' ]); exit; } catch (Exception $e) { echo json_encode([ 'success' => false, 'message' => $e->getMessage() ]); exit; } } } private function importConfig() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); try { $encrypted = $_POST['encrypted_config'] ?? ''; if (empty($encrypted)) { echo json_encode([ 'success' => false, 'message' => 'No config data provided' ]); exit; } // Generate encryption key from DokuWiki secret $key = $this->getEncryptionKey(); // Decrypt config $configContent = $this->decryptData($encrypted, $key); if ($configContent === false) { echo json_encode([ 'success' => false, 'message' => 'Decryption failed. Invalid key or corrupted file.' ]); exit; } // Validate PHP config file structure (without using eval) // Check that it starts with false, 'message' => 'Invalid config file: missing PHP opening tag' ]); exit; } // Check for dangerous patterns that shouldn't be in a config file $dangerousPatterns = [ '/\b(exec|shell_exec|system|passthru|popen|proc_open)\s*\(/i', '/\b(eval|assert|create_function)\s*\(/i', '/\b(file_get_contents|file_put_contents|fopen|fwrite|unlink|rmdir)\s*\(/i', '/\$_(GET|POST|REQUEST|SERVER|FILES|COOKIE|SESSION)\s*\[/i', '/`[^`]+`/', // Backtick execution ]; foreach ($dangerousPatterns as $pattern) { if (preg_match($pattern, $configContent)) { echo json_encode([ 'success' => false, 'message' => 'Invalid config file: contains prohibited code patterns' ]); exit; } } // Verify it looks like a valid config (has return array structure) if (!preg_match('/return\s*\[/', $configContent)) { echo json_encode([ 'success' => false, 'message' => 'Invalid config file: must contain a return array statement' ]); exit; } // Write to config file $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; // Backup existing config if (file_exists($configFile)) { $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s'); copy($configFile, $backupFile); } // Write new config if (file_put_contents($configFile, $configContent) === false) { echo json_encode([ 'success' => false, 'message' => 'Failed to write config file' ]); exit; } echo json_encode([ 'success' => true, 'message' => 'Config imported successfully' ]); exit; } catch (Exception $e) { echo json_encode([ 'success' => false, 'message' => $e->getMessage() ]); exit; } } } private function getEncryptionKey() { global $conf; // Use DokuWiki's secret as the base for encryption // This ensures the key is unique per installation return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true); } private function encryptData($data, $key) { // Use AES-256-CBC encryption $ivLength = openssl_cipher_iv_length('aes-256-cbc'); $iv = openssl_random_pseudo_bytes($ivLength); $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv); // Combine IV and encrypted data, then base64 encode return base64_encode($iv . $encrypted); } private function decryptData($encryptedData, $key) { // Decode base64 $data = base64_decode($encryptedData); if ($data === false) { return false; } // Extract IV and encrypted content $ivLength = openssl_cipher_iv_length('aes-256-cbc'); $iv = substr($data, 0, $ivLength); $encrypted = substr($data, $ivLength); // Decrypt $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv); return $decrypted; } private function clearLogFile() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $logFile = DOKU_PLUGIN . 'calendar/sync.log'; if (file_exists($logFile)) { if (file_put_contents($logFile, '')) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'message' => 'Could not clear log file']); } } else { echo json_encode(['success' => true, 'message' => 'No log file to clear']); } exit; } } private function downloadLog() { $logFile = DOKU_PLUGIN . 'calendar/sync.log'; if (file_exists($logFile)) { header('Content-Type: text/plain'); header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"'); readfile($logFile); exit; } else { echo 'No log file found'; exit; } } private function getEventStatistics() { $stats = [ 'total_events' => 0, 'total_namespaces' => 0, 'total_files' => 0, 'total_recurring' => 0, 'by_namespace' => [], 'last_scan' => '' ]; $metaDir = DOKU_INC . 'data/meta/'; $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; // Check if we have cached stats (less than 5 minutes old) if (file_exists($cacheFile)) { $cacheData = json_decode(file_get_contents($cacheFile), true); if ($cacheData && (time() - $cacheData['timestamp']) < 300) { return $cacheData['stats']; } } // Scan for events $this->scanDirectoryForStats($metaDir, '', $stats); // Count recurring events $recurringEvents = $this->findRecurringEvents(); $stats['total_recurring'] = count($recurringEvents); $stats['total_namespaces'] = count($stats['by_namespace']); $stats['last_scan'] = date('Y-m-d H:i:s'); // Cache the results file_put_contents($cacheFile, json_encode([ 'timestamp' => time(), 'stats' => $stats ])); return $stats; } private function scanDirectoryForStats($dir, $namespace, &$stats) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; // Check if this is a calendar directory if ($item === 'calendar' && is_dir($path)) { $jsonFiles = glob($path . '/*.json'); $eventCount = 0; foreach ($jsonFiles as $file) { $stats['total_files']++; $data = json_decode(file_get_contents($file), true); if ($data) { foreach ($data as $dateEvents) { $eventCount += count($dateEvents); } } } $stats['total_events'] += $eventCount; if ($eventCount > 0) { $stats['by_namespace'][$namespace] = [ 'events' => $eventCount, 'files' => count($jsonFiles) ]; } } elseif (is_dir($path)) { // Recurse into subdirectories $newNamespace = $namespace ? $namespace . ':' . $item : $item; $this->scanDirectoryForStats($path . '/', $newNamespace, $stats); } } } private function rescanEvents() { // Clear the cache to force a rescan $this->clearStatsCache(); // Get fresh statistics $stats = $this->getEventStatistics(); // Build absolute redirect URL $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Events rescanned! Found ' . $stats['total_events'] . ' events in ' . $stats['total_namespaces'] . ' namespaces.') . '&msgtype=success'; // Redirect with success message using absolute URL header('Location: ' . $redirectUrl, true, 303); exit; } private function exportAllEvents() { $metaDir = DOKU_INC . 'data/meta/'; $allEvents = []; // Collect all events $this->collectAllEvents($metaDir, '', $allEvents); // Create export package // Get current version $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; $info = file_exists($pluginInfo) ? confToHash($pluginInfo) : []; $currentVersion = isset($info['version']) ? trim($info['version']) : 'unknown'; $exportData = [ 'export_date' => date('Y-m-d H:i:s'), 'version' => $currentVersion, 'total_events' => 0, 'namespaces' => [] ]; foreach ($allEvents as $namespace => $files) { $exportData['namespaces'][$namespace] = []; foreach ($files as $filename => $events) { $exportData['namespaces'][$namespace][$filename] = $events; foreach ($events as $dateEvents) { $exportData['total_events'] += count($dateEvents); } } } // Send as download header('Content-Type: application/json'); header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"'); echo json_encode($exportData, JSON_PRETTY_PRINT); exit; } private function collectAllEvents($dir, $namespace, &$allEvents) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; // Check if this is a calendar directory if ($item === 'calendar' && is_dir($path)) { $jsonFiles = glob($path . '/*.json'); if (!isset($allEvents[$namespace])) { $allEvents[$namespace] = []; } foreach ($jsonFiles as $file) { $filename = basename($file); $data = json_decode(file_get_contents($file), true); if ($data) { $allEvents[$namespace][$filename] = $data; } } } elseif (is_dir($path)) { // Recurse into subdirectories $newNamespace = $namespace ? $namespace . ':' . $item : $item; $this->collectAllEvents($path . '/', $newNamespace, $allEvents); } } } private function importAllEvents() { global $INPUT; if (!isset($_FILES['import_file'])) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } $file = $_FILES['import_file']; if ($file['error'] !== UPLOAD_ERR_OK) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } // Read and decode the import file $importData = json_decode(file_get_contents($file['tmp_name']), true); if (!$importData || !isset($importData['namespaces'])) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } $importedCount = 0; $mergedCount = 0; // Import events foreach ($importData['namespaces'] as $namespace => $files) { $metaDir = DOKU_INC . 'data/meta/'; if ($namespace) { $metaDir .= str_replace(':', '/', $namespace) . '/'; } $calendarDir = $metaDir . 'calendar/'; // Create directory if needed if (!is_dir($calendarDir)) { mkdir($calendarDir, 0755, true); } foreach ($files as $filename => $events) { $targetFile = $calendarDir . $filename; // If file exists, merge events if (file_exists($targetFile)) { $existing = json_decode(file_get_contents($targetFile), true); if ($existing) { foreach ($events as $date => $dateEvents) { if (!isset($existing[$date])) { $existing[$date] = []; } foreach ($dateEvents as $event) { // Check if event with same ID exists $found = false; foreach ($existing[$date] as $existingEvent) { if ($existingEvent['id'] === $event['id']) { $found = true; break; } } if (!$found) { $existing[$date][] = $event; $importedCount++; } else { $mergedCount++; } } } file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT)); } } else { // New file file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT)); foreach ($events as $dateEvents) { $importedCount += count($dateEvents); } } } } // Clear cache $this->clearStatsCache(); $message = "Import complete! Imported $importedCount new events"; if ($mergedCount > 0) { $message .= ", skipped $mergedCount duplicates"; } $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; header('Location: ' . $redirectUrl, true, 303); exit; } private function previewCleanup() { global $INPUT; $cleanupType = $INPUT->str('cleanup_type', 'age'); $namespaceFilter = $INPUT->str('namespace_filter', ''); // Debug info $debug = []; $debug['cleanup_type'] = $cleanupType; $debug['namespace_filter'] = $namespaceFilter; $debug['age_value'] = $INPUT->int('age_value', 6); $debug['age_unit'] = $INPUT->str('age_unit', 'months'); $debug['range_start'] = $INPUT->str('range_start', ''); $debug['range_end'] = $INPUT->str('range_end', ''); $debug['delete_completed'] = $INPUT->bool('delete_completed', false); $debug['delete_past'] = $INPUT->bool('delete_past', false); $dataDir = DOKU_INC . 'data/meta/'; $debug['data_dir'] = $dataDir; $debug['data_dir_exists'] = is_dir($dataDir); $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); // Merge with scan debug info if (isset($this->_cleanupDebug)) { $debug = array_merge($debug, $this->_cleanupDebug); } // Return JSON for preview with debug info header('Content-Type: application/json'); echo json_encode([ 'count' => count($eventsToDelete), 'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview 'debug' => $debug ]); exit; } private function cleanupEvents() { global $INPUT; $cleanupType = $INPUT->str('cleanup_type', 'age'); $namespaceFilter = $INPUT->str('namespace_filter', ''); // Create backup first $backupDir = DOKU_PLUGIN . 'calendar/backups/'; if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip'; $this->createBackup($backupFile); // Find events to delete $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); $deletedCount = 0; // Group by file $fileGroups = []; foreach ($eventsToDelete as $evt) { $fileGroups[$evt['file']][] = $evt; } // Delete from each file foreach ($fileGroups as $file => $events) { if (!file_exists($file)) continue; $json = file_get_contents($file); $data = json_decode($json, true); if (!$data) continue; // Remove events foreach ($events as $evt) { if (isset($data[$evt['date']])) { $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) { return $e['id'] !== $evt['id']; }); // Remove date key if empty if (empty($data[$evt['date']])) { unset($data[$evt['date']]); } $deletedCount++; } } // Save file or delete if empty if (empty($data)) { unlink($file); } else { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } // Clear cache $this->clearStatsCache(); $message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile); $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; header('Location: ' . $redirectUrl, true, 303); exit; } private function findEventsToCleanup($cleanupType, $namespaceFilter) { global $INPUT; $eventsToDelete = []; $dataDir = DOKU_INC . 'data/meta/'; $debug = []; $debug['scanned_dirs'] = []; $debug['found_files'] = []; // Calculate cutoff date for age-based cleanup $cutoffDate = null; if ($cleanupType === 'age') { $ageValue = $INPUT->int('age_value', 6); $ageUnit = $INPUT->str('age_unit', 'months'); if ($ageUnit === 'years') { $ageValue *= 12; // Convert to months } $cutoffDate = date('Y-m-d', strtotime("-$ageValue months")); $debug['cutoff_date'] = $cutoffDate; } // Get date range for range-based cleanup $rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null; $rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null; // Get status filters $deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false); $deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false); // Check root calendar directory first (blank/default namespace) $rootCalendarDir = $dataDir . 'calendar'; $debug['root_calendar_dir'] = $rootCalendarDir; $debug['root_exists'] = is_dir($rootCalendarDir); if (is_dir($rootCalendarDir)) { if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') { $debug['scanned_dirs'][] = $rootCalendarDir; $files = glob($rootCalendarDir . '/*.json'); $debug['found_files'] = array_merge($debug['found_files'], $files); $this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); } } // Scan all namespace directories $namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR); $debug['namespace_dirs_found'] = $namespaceDirs; foreach ($namespaceDirs as $nsDir) { $namespace = basename($nsDir); // Skip the root 'calendar' dir (already processed above) if ($namespace === 'calendar') continue; // Check namespace filter if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) { continue; } $calendarDir = $nsDir . '/calendar'; $debug['checked_calendar_dirs'][] = $calendarDir; if (!is_dir($calendarDir)) { $debug['missing_calendar_dirs'][] = $calendarDir; continue; } $debug['scanned_dirs'][] = $calendarDir; $files = glob($calendarDir . '/*.json'); $debug['found_files'] = array_merge($debug['found_files'], $files); $this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast); } // Store debug info globally for preview $this->_cleanupDebug = $debug; return $eventsToDelete; } private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) { foreach (glob($calendarDir . '/*.json') as $file) { $json = file_get_contents($file); $data = json_decode($json, true); if (!$data) continue; foreach ($data as $date => $dateEvents) { foreach ($dateEvents as $event) { $shouldDelete = false; // Age-based if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) { $shouldDelete = true; } // Range-based if ($cleanupType === 'range' && $rangeStart && $rangeEnd) { if ($date >= $rangeStart && $date <= $rangeEnd) { $shouldDelete = true; } } // Status-based if ($cleanupType === 'status') { $isTask = isset($event['isTask']) && $event['isTask']; $isCompleted = isset($event['completed']) && $event['completed']; $isPast = $date < date('Y-m-d'); if ($deleteCompleted && $isTask && $isCompleted) { $shouldDelete = true; } if ($deletePast && !$isTask && $isPast) { $shouldDelete = true; } } if ($shouldDelete) { $eventsToDelete[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $date, 'namespace' => $namespace ?: 'default', 'file' => $file ]; } } } } } /** * Render Themes tab for sidebar widget theme selection */ private function renderThemesTab($colors = null) { global $INPUT; // Use defaults if not provided if ($colors === null) { $colors = $this->getTemplateColors(); } // Handle theme save if ($INPUT->str('action') === 'save_theme') { $theme = $INPUT->str('theme', 'matrix'); $weekStart = $INPUT->str('week_start', 'monday'); $this->saveSidebarTheme($theme); $this->saveWeekStartDay($weekStart); echo 'Customize the appearance and behavior of the sidebar calendar widget.
'; echo 'Choose which day the week calendar grid starts with:
'; echo '