getLang('menu'); } public function getMenuSort() { return 100; } /** * Return the path to the icon for the admin menu * @return string path to SVG icon */ public function getMenuIcon() { return DOKU_PLUGIN . 'calendar/images/icon.svg'; } public function forAdminOnly() { return true; } /** * Public entry point for AJAX actions routed from action.php */ public function handleAjaxAction($action) { // Verify admin privileges for all admin AJAX actions if (!auth_isadmin()) { echo json_encode(['success' => 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, Google Sync, Themes) echo '
chmod 666 ' . $logFile . '';
echo '>> redirect. The sync script logs internally, so this causes duplicate entries. Remove the redirect from your crontab.';
echo '' . hsc($cronStatus['full_line']) . ''; echo '
To enable automatic syncing, add to crontab: */30 * * * * cd ' . DOKU_PLUGIN . 'calendar && php sync_outlook.php
Note: The script logs to ' . $logFile . ' automatically. Do not use >> redirect.
'; } echo 'Location: ' . $logFile . ' โข Updates every 2 seconds
' . $this->getLang('events_manager_desc') . '
'; // Get event statistics $stats = $this->getEventStatistics(); // Statistics display echo '| ' . $this->getLang('namespace') . ' | '; echo '' . $this->getLang('events_column') . ' | '; echo '' . $this->getLang('files_column') . ' | '; echo '
|---|---|---|
' . hsc($ns ?: $this->getLang('default_ns')) . ' | ';
echo '' . $nsStats['events'] . ' | '; echo '' . $nsStats['files'] . ' | '; echo '
' . $this->getLang('important_ns_desc') . '
'; // Effects description echo '' . $this->getLang('important_ns_hint') . '
'; echo '' . $this->getLang('cleanup_desc') . '
'; echo ''; // Preview results area echo ''; // Store language strings for JavaScript $jsLang = [ 'loading_preview' => $this->getLang('loading_preview'), 'no_events_match' => $this->getLang('no_events_match'), 'debug_info' => $this->getLang('debug_info'), 'error_loading' => $this->getLang('error_loading'), 'cleanup_confirm' => $this->getLang('cleanup_confirm'), ]; echo ''; echo '' . $this->getLang('namespace_explorer_desc') . '
'; // 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 '๐ก Restore: Click the ๐ Restore button to reinstall from a backup. This uses DokuWiki\'s Extension Manager for safe installation. Alternatively, download the ZIP and upload via Admin โ Extension Manager โ Install.
'; echo '| '; echo ' | Backup File | '; echo 'Size | '; echo 'Actions | '; echo '
|---|---|---|---|
| '; echo ' | ' . hsc($filename) . ' | ';
echo '' . $size . ' | '; echo ''; echo '๐ฅ Download'; echo ''; echo ''; echo ' | '; echo '
No backups yet. Click "Create Backup Now" to create your first backup.
'; } echo '' . $this->getLang('no_recurring_found') . '
'; return; } // Search bar echo '| ' . $this->getLang('col_title') . ' โ | '; echo '' . $this->getLang('col_namespace') . ' โ | '; echo '' . $this->getLang('col_pattern') . ' โ | '; echo '' . $this->getLang('col_range') . ' โ | '; echo '' . $this->getLang('col_count') . ' โ | '; echo '' . $this->getLang('col_source') . ' โ | '; echo '' . $this->getLang('col_actions') . ' | '; echo '
|---|---|---|---|---|---|---|
| ' . hsc($series['title']) . ' | '; echo '' . hsc($series['namespace'] ?: $this->getLang('default_ns')) . ' | ';
echo '' . hsc($displayPattern) . ' | '; echo '' . $dateRange . ' | '; echo '' . $series['count'] . ' | '; echo '' . $sourceLabel . ' | '; echo ''; // Prepare JS data - include recurrence metadata $jsTitle = hsc(addslashes($series['title'])); $jsNs = hsc($series['namespace']); $jsCount = $series['count']; $jsFirst = hsc($series['firstDate']); $jsLast = hsc($series['lastDate'] ?? $series['firstDate']); $jsPattern = hsc($series['pattern']); $jsHasFlag = $series['hasFlag'] ? 'true' : 'false'; $jsTime = hsc($series['time'] ?? ''); $jsEndTime = hsc($series['endTime'] ?? ''); $jsColor = hsc($series['color'] ?? ''); // Recurrence metadata for edit dialog $jsRecurrenceType = hsc($series['recurrenceType'] ?? ''); $jsRecurrenceInterval = intval($series['recurrenceInterval'] ?? 1); $jsWeekDays = json_encode($series['weekDays'] ?? []); $jsMonthlyType = hsc($series['monthlyType'] ?? ''); $jsMonthDay = intval($series['monthDay'] ?? 0); $jsOrdinalWeek = intval($series['ordinalWeek'] ?? 1); $jsOrdinalDay = intval($series['ordinalDay'] ?? 0); echo ''; echo ''; echo ''; echo ' | '; echo '
' . sprintf($this->getLang('total_series'), count($recurringEvents)) . '
'; } /** * 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 = CalendarFileHandler::readJson($jsonFile); 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 = CalendarFileHandler::readJson($file); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 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 { CalendarFileHandler::writeJson($file, $data); } } } } 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 = CalendarFileHandler::readJson($file); if (!$data || !is_array($data)) continue; foreach ($data as $dateKey => $dayEvents) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (!is_array($dayEvents)) continue; foreach ($dayEvents as $idx => $event) { if (!isset($event['title'])) continue; 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) ? CalendarFileHandler::readJson($file) : []; 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; CalendarFileHandler::writeJson($file, $fileData); $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 = CalendarFileHandler::readJson($entry['file']); 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 { CalendarFileHandler::writeJson($entry['file'], $data); } } } $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 = CalendarFileHandler::readJson($entry['file']); 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); CalendarFileHandler::writeJson($entry['file'], $data); } } $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 = CalendarFileHandler::readJson($file); if (!$data) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (!is_array($dayEvents)) continue; foreach ($dayEvents as $k => &$evt) { if (!isset($evt['title'])) continue; $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) { CalendarFileHandler::writeJson($file, $data); } } $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 = CalendarFileHandler::readJson($move['file']); 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 { CalendarFileHandler::writeJson($move['file'], $data); } } // 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) ? CalendarFileHandler::readJson($file) : []; if (!is_array($data)) $data = []; if (!isset($data[$move['newDate']])) $data[$move['newDate']] = []; $data[$move['newDate']][] = $move['event']; CalendarFileHandler::writeJson($file, $data); $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 = CalendarFileHandler::readJson($entry['file']); 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 { CalendarFileHandler::writeJson($entry['file'], $data); } } // 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) ? CalendarFileHandler::readJson($file) : []; 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; CalendarFileHandler::writeJson($file, $fileData); $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 = CalendarFileHandler::readJson($file); if (!$data) continue; foreach ($data as $dateKey => $eventList) { // Skip non-date keys (like "mapping" or other metadata) // Date keys should be in YYYY-MM-DD format if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; // Skip if eventList is not an array (corrupted data) if (!is_array($eventList)) continue; foreach ($eventList as $event) { // Skip if event is not an array if (!is_array($event)) continue; // Skip if event doesn't have required fields if (empty($event['id']) || empty($event['title'])) continue; $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 = CalendarFileHandler::readJson($file); if (!$data) continue; foreach ($data as $dateKey => $eventList) { // Skip non-date keys (like "mapping" or other metadata) // Date keys should be in YYYY-MM-DD format if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; // Skip if eventList is not an array (corrupted data) if (!is_array($eventList)) continue; foreach ($eventList as $event) { // Skip if event is not an array if (!is_array($event)) continue; // Skip if event doesn't have required fields if (empty($event['id']) || empty($event['title'])) continue; $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 = CalendarFileHandler::readJson($file); if (!$data) continue; foreach ($data as $dateKey => $events) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (!is_array($events)) continue; foreach ($events as $event) { if (!isset($event['title']) || !isset($event['id'])) continue; 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 = CalendarFileHandler::readJson($file); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => $events) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (!is_array($events)) continue; $filtered = []; foreach ($events as $event) { if (!isset($event['title'])) { $filtered[] = $event; continue; } $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 { CalendarFileHandler::writeJson($file, $data); } } } } $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'); $newNamespace = $INPUT->str('new_namespace'); // New recurrence parameters $recurrenceType = $INPUT->str('recurrence_type', ''); $recurrenceInterval = $INPUT->int('recurrence_interval', 0); $weekDaysStr = $INPUT->str('week_days', ''); $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; $monthlyType = $INPUT->str('monthly_type', ''); $monthDay = $INPUT->int('month_day', 0); $ordinalWeek = $INPUT->int('ordinal_week', 0); $ordinalDay = $INPUT->int('ordinal_day', 0); // 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 and recurrence metadata 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 = CalendarFileHandler::readJson($file); if (!$data || !is_array($data)) continue; $modified = false; foreach ($data as $dateKey => &$dayEvents) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (!is_array($dayEvents)) continue; foreach ($dayEvents as $key => &$event) { if (!isset($event['title'])) continue; // 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; // Update recurrence metadata if provided if (!empty($recurrenceType)) { $event['recurrenceType'] = $recurrenceType; } if ($recurrenceInterval > 0) { $event['recurrenceInterval'] = $recurrenceInterval; } if (!empty($weekDays)) { $event['weekDays'] = $weekDays; } if (!empty($monthlyType)) { $event['monthlyType'] = $monthlyType; if ($monthlyType === 'dayOfMonth' && $monthDay > 0) { $event['monthDay'] = $monthDay; unset($event['ordinalWeek']); unset($event['ordinalDay']); } elseif ($monthlyType === 'ordinalWeekday') { $event['ordinalWeek'] = $ordinalWeek; $event['ordinalDay'] = $ordinalDay; unset($event['monthDay']); } } $count++; $modified = true; } unset($event); } unset($dayEvents); if ($modified) { CalendarFileHandler::writeJson($file, $data); } } } // Pass 2: Handle recurrence pattern changes - reschedule future events $needsReschedule = !empty($recurrenceType) && $recurrenceInterval > 0; if ($needsReschedule && $count > 0) { // Get all events with the NEW title $allEvents = $this->getRecurringSeriesEvents($newTitle, $newNamespace); if (count($allEvents) > 1) { // Sort by date usort($allEvents, function($a, $b) { return strcmp($a['date'], $b['date']); }); $firstDate = new DateTime($allEvents[0]['date']); $today = new DateTime(); $today->setTime(0, 0, 0); // Find the anchor date - either first date or first future date $anchorDate = $firstDate; $anchorIndex = 0; for ($i = 0; $i < count($allEvents); $i++) { $eventDate = new DateTime($allEvents[$i]['date']); if ($eventDate >= $today) { $anchorDate = $eventDate; $anchorIndex = $i; break; } } // Get template from anchor event $template = $allEvents[$anchorIndex]['event']; // Remove all future events (we'll recreate them) for ($i = $anchorIndex + 1; $i < count($allEvents); $i++) { $entry = $allEvents[$i]; $data = CalendarFileHandler::readJson($entry['file']); 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 { CalendarFileHandler::writeJson($entry['file'], $data); } } // Recreate with new pattern $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); // Calculate how many future events we need (use same count as before) $futureCount = count($allEvents) - $anchorIndex - 1; if ($futureCount < 1) $futureCount = 12; // Default to 12 future occurrences // Generate new dates based on recurrence pattern $newDates = $this->generateRecurrenceDates( $anchorDate->format('Y-m-d'), $recurrenceType, $recurrenceInterval, $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $futureCount ); // Create events for new dates (skip first since it's the anchor) for ($i = 1; $i < count($newDates); $i++) { $dateKey = $newDates[$i]; list($year, $month) = explode('-', $dateKey); $file = $targetDir . '/' . sprintf('%04d-%02d.json', $year, $month); $fileData = file_exists($file) ? CalendarFileHandler::readJson($file) : []; if (!is_array($fileData)) $fileData = []; if (!isset($fileData[$dateKey])) $fileData[$dateKey] = []; $newEvent = $template; $newEvent['id'] = $baseId . '-respace-' . $i; $newEvent['recurrenceType'] = $recurrenceType; $newEvent['recurrenceInterval'] = $recurrenceInterval; if (!empty($weekDays)) $newEvent['weekDays'] = $weekDays; if (!empty($monthlyType)) $newEvent['monthlyType'] = $monthlyType; if ($monthlyType === 'dayOfMonth' && $monthDay > 0) $newEvent['monthDay'] = $monthDay; if ($monthlyType === 'ordinalWeekday') { $newEvent['ordinalWeek'] = $ordinalWeek; $newEvent['ordinalDay'] = $ordinalDay; } $fileData[$dateKey][] = $newEvent; CalendarFileHandler::writeJson($file, $fileData); } } } $changes = []; if ($oldTitle !== $newTitle) $changes[] = "title"; if (!empty($startTime) || !empty($endTime)) $changes[] = "time"; if (!empty($recurrenceType)) $changes[] = "pattern"; if ($newNamespace !== $oldNamespace) $changes[] = "namespace"; $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : ""; $this->clearStatsCache(); $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage'); } /** * Generate dates for a recurrence pattern */ private function generateRecurrenceDates($startDate, $type, $interval, $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $count) { $dates = [$startDate]; $currentDate = new DateTime($startDate); $maxIterations = $count * 100; // Safety limit $iterations = 0; while (count($dates) < $count + 1 && $iterations < $maxIterations) { $iterations++; $currentDate->modify('+1 day'); $shouldInclude = false; switch ($type) { case 'daily': $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days; $shouldInclude = ($daysSinceStart % $interval === 0); break; case 'weekly': $daysSinceStart = (new DateTime($startDate))->diff($currentDate)->days; $weeksSinceStart = floor($daysSinceStart / 7); $isCorrectWeek = ($weeksSinceStart % $interval === 0); $currentDayOfWeek = (int)$currentDate->format('w'); $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); $shouldInclude = $isCorrectWeek && $isDaySelected; break; case 'monthly': $startDT = new DateTime($startDate); $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + ($currentDate->format('n') - $startDT->format('n')); $isCorrectMonth = ($monthsSinceStart > 0 && $monthsSinceStart % $interval === 0); if (!$isCorrectMonth) break; if ($monthlyType === 'dayOfMonth' || empty($monthlyType)) { $targetDay = $monthDay ?: (int)$startDT->format('j'); $currentDay = (int)$currentDate->format('j'); $daysInMonth = (int)$currentDate->format('t'); $effectiveTargetDay = min($targetDay, $daysInMonth); $shouldInclude = ($currentDay === $effectiveTargetDay); } else { $shouldInclude = $this->isOrdinalWeekdayAdmin($currentDate, $ordinalWeek, $ordinalDay); } break; case 'yearly': $startDT = new DateTime($startDate); $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); $isCorrectYear = ($yearsSinceStart > 0 && $yearsSinceStart % $interval === 0); $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); $shouldInclude = $isCorrectYear && $sameMonthDay; break; } if ($shouldInclude) { $dates[] = $currentDate->format('Y-m-d'); } } return $dates; } /** * Check if a date is the Nth occurrence of a weekday in its month (admin version) */ private function isOrdinalWeekdayAdmin($date, $ordinalWeek, $targetDayOfWeek) { $currentDayOfWeek = (int)$date->format('w'); if ($currentDayOfWeek !== $targetDayOfWeek) return false; $dayOfMonth = (int)$date->format('j'); $daysInMonth = (int)$date->format('t'); if ($ordinalWeek === -1) { $daysRemaining = $daysInMonth - $dayOfMonth; return $daysRemaining < 7; } else { $weekNumber = ceil($dayOfMonth / 7); return $weekNumber === $ordinalWeek; } } /** * 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 = CalendarFileHandler::readJson($oldFile); 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 CalendarFileHandler::writeJson($oldFile, $oldData); // 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 = CalendarFileHandler::readJson($newFile) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; CalendarFileHandler::writeJson($newFile, $newData); $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 = CalendarFileHandler::readJson($oldFile); 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 { CalendarFileHandler::writeJson($oldFile, $oldData); } // 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 = CalendarFileHandler::readJson($newFile) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; CalendarFileHandler::writeJson($newFile, $newData); $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)) { CalendarFileHandler::writeJson($placeholderFile, []); } $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 = CalendarFileHandler::readJson($file); if ($data) { foreach ($data as $events) { if (is_array($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 = CalendarFileHandler::readJson($file); if ($data) { foreach ($data as $date => &$events) { foreach ($events as &$event) { if (isset($event['namespace']) && $event['namespace'] === $oldNamespace) { $event['namespace'] = $newNamespace; $eventsUpdated++; } } } CalendarFileHandler::writeJson($file, $data); } } // 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 = CalendarFileHandler::readJson($file); 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 CalendarFileHandler::writeJson($file, $data); } } $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; } // Get log file from data directory (writable) $logFile = $this->getSyncLogPath(); $logDir = dirname($logFile); // Ensure log directory exists if (!is_dir($logDir)) { if (!@mkdir($logDir, 0755, true)) { echo json_encode(['success' => false, 'message' => 'Cannot create log directory: ' . $logDir]); exit; } } // Ensure log file exists and is writable if (!file_exists($logFile)) { if (!@touch($logFile)) { echo json_encode(['success' => false, 'message' => 'Cannot create log file: ' . $logFile]); exit; } @chmod($logFile, 0666); } // Check if we can write to the log if (!is_writable($logFile)) { echo json_encode(['success' => false, 'message' => 'Log file not writable: ' . $logFile . ' - Run: chmod 666 ' . $logFile]); exit; } // Find PHP binary $phpPath = $this->findPhpBinary(); if (!$phpPath) { echo json_encode(['success' => false, 'message' => 'Cannot find PHP binary']); exit; } // Get plugin directory for cd command $pluginDir = DOKU_PLUGIN . 'calendar'; // Build command - NO --verbose flag because the script logs internally // The script writes directly to the log file, so we don't need to capture stdout $command = sprintf( 'cd %s && %s sync_outlook.php 2>&1', escapeshellarg($pluginDir), $phpPath ); // Log that we're starting $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); @file_put_contents($logFile, "[$timestamp] [ADMIN] Command: $command\n", FILE_APPEND); // Execute sync $output = []; $returnCode = 0; exec($command, $output, $returnCode); // Only log output if there was an error (the script logs its own progress) if ($returnCode !== 0 && !empty($output)) { @file_put_contents($logFile, "[$timestamp] [ADMIN] Error output:\n" . implode("\n", $output) . "\n", FILE_APPEND); } // Check results if ($returnCode === 0) { echo json_encode([ 'success' => true, 'message' => 'Sync completed! Check log for details.' ]); } else { $errorMsg = 'Sync failed (exit code: ' . $returnCode . ')'; if (!empty($output)) { $lastLines = array_slice($output, -3); $errorMsg .= ' - ' . implode(' | ', $lastLines); } 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 (sync_state.json and sync.log are now in data/meta/calendar/) $preserveFiles = ['sync_config.php']; $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'); } } /** * Restore a backup using DokuWiki's extension manager * This ensures proper permissions and follows DokuWiki's standard installation process */ 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; if (!file_exists($backupPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } // Try to use DokuWiki's extension manager helper $extensionHelper = plugin_load('helper', 'extension_extension'); if (!$extensionHelper) { // Extension manager not available - provide manual instructions $this->redirect('DokuWiki Extension Manager not available. Please install manually: Download the backup, go to Admin โ Extension Manager โ Install, and upload the ZIP file.', 'error', 'update'); return; } try { // Set the extension we're working with $extensionHelper->setExtension('calendar'); // Use DokuWiki's extension manager to install from the local file // This handles all permissions and file operations properly $installed = $extensionHelper->installFromLocal($backupPath, true); // true = overwrite if ($installed) { $this->redirect('Plugin restored from backup: ' . $filename . ' (via Extension Manager)', 'success', 'update'); } else { // Get any error message from the extension helper $errors = $extensionHelper->getErrors(); $errorMsg = !empty($errors) ? implode(', ', $errors) : 'Unknown error'; $this->redirect('Restore failed: ' . $errorMsg, 'error', 'update'); } } catch (Exception $e) { $this->redirect('Restore failed: ' . $e->getMessage(), 'error', '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 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', ]; foreach ($possiblePaths as $path) { if (is_executable($path)) { return $path; } } // Try using 'which' to find php $which = trim(shell_exec('which php 2>/dev/null') ?? ''); if (!empty($which) && is_executable($which)) { return $which; } // 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 = $this->getSyncLogPath(); $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) // Accept both "return [" and "return array(" syntax if (!preg_match('/return\s*(\[|array\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 = $this->getSyncLogPath(); // Check if file exists if (!file_exists($logFile)) { // Try to create empty file if (@touch($logFile)) { echo json_encode(['success' => true, 'message' => 'Log file created']); } else { echo json_encode(['success' => false, 'message' => 'Log file does not exist and cannot be created: ' . $logFile]); } exit; } // Check if writable if (!is_writable($logFile)) { echo json_encode(['success' => false, 'message' => 'Log file not writable. Run: sudo chmod 666 ' . $logFile]); exit; } // Try to clear it $result = file_put_contents($logFile, ''); if ($result !== false) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'message' => 'file_put_contents failed on: ' . $logFile]); } exit; } } private function downloadLog() { $logFile = $this->getSyncLogPath(); 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 = CalendarFileHandler::readJson($file); if ($data) { foreach ($data as $dateKey => $dateEvents) { // Skip non-date keys (like "mapping" or other metadata) if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; if (is_array($dateEvents)) { // Only count events that have id and title foreach ($dateEvents as $event) { if (is_array($event) && !empty($event['id']) && !empty($event['title'])) { $eventCount++; } } } } } } $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) { if (is_array($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 = CalendarFileHandler::readJson($file); 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++; } } } CalendarFileHandler::writeJson($targetFile, $existing); } } else { // New file CalendarFileHandler::writeJson($targetFile, $events); foreach ($events as $dateEvents) { if (is_array($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 { CalendarFileHandler::writeJson($file, $data); } } // 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 Google Calendar Sync tab */ private function renderGoogleSyncTab($colors = null) { global $INPUT; if ($colors === null) { $colors = $this->getTemplateColors(); } // Load Google sync class require_once __DIR__ . '/classes/GoogleCalendarSync.php'; $googleSync = new GoogleCalendarSync(); $status = $googleSync->getStatus(); // Handle config save if ($INPUT->str('action') === 'save_google_config') { $clientId = $INPUT->str('google_client_id'); $clientSecret = $INPUT->str('google_client_secret'); $calendarId = $INPUT->str('google_calendar_id', 'primary'); if ($clientId && $clientSecret) { $googleSync->saveConfig($clientId, $clientSecret, $calendarId); echo 'Calendar: ' . htmlspecialchars($status['calendar_id']) . '
'; } echo '' . DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callbackโ Connected to Google Calendar
'; echo ''; } else { echo 'Click below to authorize access to your Google Calendar.
'; echo ''; } echo 'Import events from Google Calendar to DokuWiki.
'; echo 'Export events from DokuWiki to Google Calendar.
'; echo 'Customize the appearance and behavior of the sidebar calendar widget.
'; echo 'Choose which day the week calendar grid starts with:
'; echo 'Choose whether the Today/Tomorrow/Important Events sections are expanded or collapsed by default:
'; echo 'Show or hide the CPU/Memory load indicator bars in the event panel:
'; echo '