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_events') { $this->moveEvents(); } 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 === '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 === 'export_config') { $this->exportConfig(); } elseif ($action === 'import_config') { $this->importConfig(); } elseif ($action === 'get_log') { $this->getLog(); } 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(); } } public function html() { global $INPUT; // Get current tab - default to 'update' (Update Plugin tab) $tab = $INPUT->str('tab', 'update'); // Tab navigation echo '
'; echo '๐Ÿ“ฆ Update Plugin'; echo 'โš™๏ธ Outlook Sync'; echo '๐Ÿ“… Manage Events'; echo '
'; // Render appropriate tab if ($tab === 'config') { $this->renderConfigTab(); } elseif ($tab === 'manage') { $this->renderManageTab(); } else { $this->renderUpdateTab(); } } private function renderConfigTab() { global $INPUT; // Load current config $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; $config = []; if (file_exists($configFile)) { $config = include $configFile; } // Show message if present if ($INPUT->has('msg')) { $msg = hsc($INPUT->str('msg')); $type = $INPUT->str('msgtype', 'success'); $class = ($type === 'success') ? 'msg success' : 'msg error'; echo "
"; echo $msg; echo "
"; } echo '

Outlook Sync Configuration

'; // Import/Export buttons echo '
'; echo ''; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo ''; // Azure Credentials echo '
'; echo '

Microsoft Azure App Credentials

'; echo '

Register at Azure Portal โ†’ App registrations

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '

โš ๏ธ Keep this secret safe!

'; echo '
'; // Outlook Settings echo '
'; echo '

Outlook Settings

'; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; // end grid echo '
'; // Important Namespaces for Sidebar Widget echo '
'; echo '

๐Ÿ“Œ Important Namespaces (Sidebar Widget)

'; echo '

Events from these namespaces will be highlighted in purple in the sidebar widget

'; echo ''; echo '

Comma-separated list of namespace names

'; echo '
'; // Sync Options echo '
'; echo '

Sync Options

'; $syncCompleted = isset($config['sync_completed_tasks']) ? $config['sync_completed_tasks'] : false; echo ''; $deleteOutlook = isset($config['delete_outlook_events']) ? $config['delete_outlook_events'] : true; echo ''; $syncAll = isset($config['sync_all_namespaces']) ? $config['sync_all_namespaces'] : true; echo ''; // Namespace selection (shown when sync_all is unchecked) echo '
'; echo ''; // Get available namespaces $availableNamespaces = $this->getAllNamespaces(); $selectedNamespaces = isset($config['sync_namespaces']) ? $config['sync_namespaces'] : []; echo '
'; echo ''; foreach ($availableNamespaces as $ns) { if ($ns !== '') { $checked = in_array($ns, $selectedNamespaces) ? 'checked' : ''; echo ''; } } echo '
'; echo '
'; echo ''; echo '
'; // Namespace and Color Mapping - Side by Side echo '
'; // Namespace Mapping echo '
'; echo '

Namespace โ†’ Category

'; echo '

One per line: namespace=Category

'; echo ''; echo '
'; // Color Mapping with Color Picker echo '
'; echo '

๐ŸŽจ Event Color โ†’ Category

'; echo '

Map calendar colors to Outlook categories

'; // Define calendar colors and Outlook categories (only the main 6 colors) $calendarColors = [ '#3498db' => 'Blue', '#2ecc71' => 'Green', '#e74c3c' => 'Red', '#f39c12' => 'Orange', '#9b59b6' => 'Purple', '#1abc9c' => 'Teal' ]; $outlookCategories = [ 'Blue category', 'Green category', 'Orange category', 'Red category', 'Yellow category', 'Purple category' ]; // Load existing color mappings $existingMappings = isset($config['color_mapping']) && is_array($config['color_mapping']) ? $config['color_mapping'] : []; // Display color mapping rows echo '
'; $rowIndex = 0; foreach ($calendarColors as $hexColor => $colorName) { $selectedCategory = isset($existingMappings[$hexColor]) ? $existingMappings[$hexColor] : ''; echo '
'; // Color preview box echo '
'; // Color name echo '' . $colorName . ''; // Arrow echo 'โ†’'; // Outlook category dropdown echo ''; // Hidden input for the hex color echo ''; echo '
'; $rowIndex++; } echo '
'; // Hidden input to track number of color mappings echo ''; echo '
'; echo '
'; // end grid // Submit button echo ''; echo '
'; // JavaScript for Import/Export echo ''; // Sync Controls Section echo '
'; echo '

๐Ÿ”„ Sync Controls

'; // Check cron job status $cronStatus = $this->getCronStatus(); // Check log file permissions $logFile = DOKU_PLUGIN . 'calendar/sync.log'; $logWritable = is_writable($logFile) || is_writable(dirname($logFile)); echo '
'; echo ''; echo ''; if ($cronStatus['active']) { echo 'โฐ ' . hsc($cronStatus['frequency']) . ''; } else { echo 'โš ๏ธ No cron job detected'; } echo ''; echo '
'; // Show permission warning if log not writable if (!$logWritable) { echo '
'; echo 'โš ๏ธ Log file not writable. Run: chmod 666 ' . $logFile . ''; echo '
'; } // Show debug info if cron detected if ($cronStatus['active'] && !empty($cronStatus['full_line'])) { echo '
'; echo 'Show cron details'; echo '
' . hsc($cronStatus['full_line']) . '
'; echo '
'; } if (!$cronStatus['active']) { echo '

To enable automatic syncing, add to crontab: */30 * * * * cd ' . DOKU_PLUGIN . 'calendar && php sync_outlook.php

'; } echo '
'; // JavaScript for Run Sync Now echo ''; // Log Viewer Section - More Compact echo '
'; echo '

๐Ÿ“œ Live Sync Log

'; echo '

Updates every 2 seconds

'; // Log viewer container echo '
'; // Log header - More compact echo '
'; echo 'sync.log'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
'; // Log content - Reduced height to 250px echo '
Loading log...
'; echo '
'; echo '
'; // JavaScript for log viewer echo ''; } private function renderManageTab() { global $INPUT; // Show message if present if ($INPUT->has('msg')) { $msg = hsc($INPUT->str('msg')); $type = $INPUT->str('msgtype', 'success'); echo "
"; echo $msg; echo "
"; } echo '

Manage Calendar Events

'; // Events Manager Section - NEW! echo '
'; echo '

๐Ÿ“Š Events Manager

'; echo '

Scan, export, and import all calendar events across all namespaces.

'; // Get event statistics $stats = $this->getEventStatistics(); // Statistics display echo '
'; echo '
'; echo '
'; echo '
' . $stats['total_events'] . '
'; echo '
Total Events
'; echo '
'; echo '
'; echo '
' . $stats['total_namespaces'] . '
'; echo '
Namespaces
'; echo '
'; echo '
'; echo '
' . $stats['total_files'] . '
'; echo '
JSON Files
'; echo '
'; echo '
'; echo '
' . $stats['total_recurring'] . '
'; echo '
Recurring
'; echo '
'; echo '
'; // Last scan time if (!empty($stats['last_scan'])) { echo '
Last scanned: ' . hsc($stats['last_scan']) . '
'; } echo '
'; // Action buttons echo '
'; // Rescan button echo '
'; echo ''; echo ''; echo '
'; // Export button echo '
'; echo ''; echo ''; echo '
'; // Import button (with file upload) echo '
'; echo ''; echo ''; echo '
'; echo '
'; // Breakdown by namespace if (!empty($stats['by_namespace'])) { echo '
'; echo 'View Breakdown by Namespace'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($stats['by_namespace'] as $ns => $nsStats) { echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
NamespaceEventsFiles
' . hsc($ns ?: '(default)') . '' . $nsStats['events'] . '' . $nsStats['files'] . '
'; echo '
'; echo '
'; } echo '
'; // Cleanup Events Section - Redesigned for compact, sleek look echo '
'; echo '

๐Ÿงน Cleanup Old Events

'; echo '

Delete events based on criteria below. Automatic backup created before deletion.

'; echo '
'; echo ''; // Compact options layout echo '
'; // Radio buttons in a row echo '
'; echo ''; echo ''; echo ''; echo '
'; // Age options echo '
'; echo 'Delete events older than:'; echo ''; echo ''; echo '
'; // Status options echo '
'; echo 'Delete:'; echo ''; echo ''; echo '
'; // Range options echo '
'; echo 'From:'; echo ''; echo 'To:'; echo ''; echo '
'; echo '
'; // Namespace filter - compact echo '
'; echo ''; echo ''; echo '
'; // Action buttons - compact row echo '
'; echo ''; echo ''; echo 'โš ๏ธ Backup created automatically'; echo '
'; echo '
'; // Preview results area echo ''; echo ''; echo '
'; // Recurring Events Section echo '
'; echo '

๐Ÿ”„ Recurring Events

'; $recurringEvents = $this->findRecurringEvents(); if (empty($recurringEvents)) { echo '

No recurring events found.

'; } else { // Search bar echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($recurringEvents as $series) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
Title โ‡…Namespace โ‡…Pattern โ‡…First โ‡…Count โ‡…Actions
' . hsc($series['title']) . '' . hsc($series['namespace'] ?: '(default)') . '' . hsc($series['pattern']) . '' . hsc($series['firstDate']) . '' . $series['count'] . ''; echo ''; echo ''; echo '
'; echo '
'; echo '

Total: ' . count($recurringEvents) . ' series

'; } echo '
'; // Compact Tree-based Namespace Manager echo '
'; echo '

๐Ÿ“ Namespace Explorer

'; echo '

Select events and move between namespaces. Drag & drop also supported.

'; // Search bar echo '
'; echo ''; echo '
'; $eventsByNamespace = $this->getEventsByNamespace(); // Control bar echo '
'; echo ''; echo '
'; echo ''; echo ''; echo ''; echo 'Move to:'; echo ''; echo ''; echo ''; foreach (array_keys($eventsByNamespace) as $ns) { if ($ns !== '') { echo ''; } } echo ''; echo ''; echo ''; echo '0 selected'; echo '
'; echo '
'; // Event list with checkboxes echo '
'; echo '
'; foreach ($eventsByNamespace as $namespace => $data) { $nsId = 'ns_' . md5($namespace); $eventCount = count($data['events']); echo '
'; // Namespace header - ultra compact echo '
'; echo '
'; echo 'โ–ถ'; echo ''; echo '๐Ÿ“ ' . hsc($namespace ?: '(default)') . ''; echo '
'; echo '
'; echo '' . $eventCount . ''; echo ''; echo '
'; echo '
'; // Events - ultra compact echo ''; echo '
'; } echo '
'; echo '
'; // Drop zones - ultra compact echo '
'; echo '
๐ŸŽฏ Drop Target
'; echo '
'; foreach (array_keys($eventsByNamespace) as $namespace) { echo '
'; echo '
๐Ÿ“ ' . hsc($namespace ?: '(default)') . '
'; echo '
Drop here
'; echo '
'; } echo '
'; echo '
'; echo '
'; // end grid echo '
'; echo '
'; // JavaScript echo ''; } private function renderUpdateTab() { global $INPUT; echo '

๐Ÿ“ฆ Update Plugin

'; // Clear Cache button echo '
'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '

Clear the DokuWiki cache if changes aren\'t appearing or after updating the plugin.

'; echo '
'; // Show message if present if ($INPUT->has('msg')) { $msg = hsc($INPUT->str('msg')); $type = $INPUT->str('msgtype', 'success'); $class = ($type === 'success') ? 'msg success' : 'msg error'; echo "
"; echo $msg; echo "
"; } // Show current version $pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt'; $info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => '']; if (file_exists($pluginInfo)) { $info = array_merge($info, confToHash($pluginInfo)); } echo '
'; echo '

' . hsc($info['name']) . '

'; echo '
'; echo '
Version: ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')
'; echo '
Author: ' . hsc($info['author']) . ($info['email'] ? ' <' . hsc($info['email']) . '>' : '') . '
'; if ($info['desc']) { echo '
Description: ' . hsc($info['desc']) . '
'; } echo '
Location: ' . DOKU_PLUGIN . 'calendar/
'; echo '
'; // Check permissions $pluginDir = DOKU_PLUGIN . 'calendar/'; $pluginWritable = is_writable($pluginDir); $parentWritable = is_writable(DOKU_PLUGIN); echo '
'; if ($pluginWritable && $parentWritable) { echo '

โœ… 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/

'; echo '

Or: chown -R www-data:www-data ' . DOKU_PLUGIN . 'calendar/

'; } echo '
'; echo '
'; // Changelog section echo '
'; echo '

๐Ÿ“‹ Recent Changes

'; $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md'; if (file_exists($changelogFile)) { $changelog = file_get_contents($changelogFile); // Parse markdown and show last 10 versions $lines = explode("\n", $changelog); $versionsShown = 0; $maxVersions = 10; $inVersion = false; $changelogHtml = '
'; foreach ($lines as $line) { $line = trim($line); // Version header (## Version X.X.X) if (preg_match('/^## Version (.+)$/', $line, $matches)) { if ($versionsShown >= $maxVersions) break; $versionsShown++; $inVersion = true; $changelogHtml .= '
'; $changelogHtml .= '
๐Ÿท๏ธ ' . hsc($matches[1]) . '
'; } // List items (- **Added:** text) elseif (preg_match('/^- \*\*(.+?):\*\* (.+)$/', $line, $matches)) { $type = $matches[1]; $description = $matches[2]; // Color-code by type $color = '#666'; $icon = 'โ€ข'; if ($type === 'Added') { $color = '#28a745'; $icon = 'โœจ'; } elseif ($type === 'Fixed') { $color = '#dc3545'; $icon = '๐Ÿ”ง'; } elseif ($type === 'Changed') { $color = '#7b1fa2'; $icon = '๐Ÿ”„'; } elseif ($type === 'Improved') { $color = '#ff9800'; $icon = 'โšก'; } elseif ($type === 'Development') { $color = '#6c757d'; $icon = '๐Ÿ› ๏ธ'; } $changelogHtml .= '
'; $changelogHtml .= '' . $icon . ' ' . hsc($type) . ': ' . hsc($description) . ''; $changelogHtml .= '
'; } // Close version block on empty line after items elseif ($inVersion && $line === '' && $versionsShown > 0) { $changelogHtml .= '
'; $inVersion = false; } } // Close last version if still open if ($inVersion) { $changelogHtml .= '
'; } $changelogHtml .= '
'; echo $changelogHtml; } else { echo '

Changelog not available

'; } echo ''; // Upload form echo '
'; echo '

Upload New Version

'; echo '

Upload a calendar plugin ZIP file to update. Your configuration will be preserved.

'; echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo '
'; // Warning box echo '
'; echo '

โš ๏ธ Important Notes

'; echo ''; echo '
'; // Backup list $backupDir = DOKU_PLUGIN; $backups = glob($backupDir . 'calendar*.zip'); // Filter to only show files that look like backups (not the uploaded plugin files) $backups = array_filter($backups, function($file) { $name = basename($file); // Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin) return $name !== 'calendar.zip'; }); if (!empty($backups)) { rsort($backups); // Newest first echo '
'; echo '

๐Ÿ“ Available Backups

'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($backups as $backup) { $filename = basename($backup); $size = $this->formatBytes(filesize($backup)); echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
Backup FileSizeActions
' . hsc($filename) . '' . $size . ''; echo '๐Ÿ“ฅ Download'; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo '
'; } echo ''; } private function saveConfig() { global $INPUT; // Load existing config to preserve all settings $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; $existingConfig = []; if (file_exists($configFile)) { $existingConfig = include $configFile; } // Update only the fields from the form - preserve everything else $config = $existingConfig; // Update basic fields $config['tenant_id'] = $INPUT->str('tenant_id'); $config['client_id'] = $INPUT->str('client_id'); $config['client_secret'] = $INPUT->str('client_secret'); $config['user_email'] = $INPUT->str('user_email'); $config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles'); $config['default_category'] = $INPUT->str('default_category', 'Blue category'); $config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15); $config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks'); $config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events'); $config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces'); $config['sync_namespaces'] = $INPUT->arr('sync_namespaces'); $config['important_namespaces'] = $INPUT->str('important_namespaces', 'important'); // Parse category mapping $config['category_mapping'] = []; $mappingText = $INPUT->str('category_mapping'); if ($mappingText) { $lines = explode("\n", $mappingText); foreach ($lines as $line) { $line = trim($line); if (empty($line)) continue; $parts = explode('=', $line, 2); if (count($parts) === 2) { $config['category_mapping'][trim($parts[0])] = trim($parts[1]); } } } // Parse color mapping from dropdown selections $config['color_mapping'] = []; $colorMappingCount = $INPUT->int('color_mapping_count', 0); for ($i = 0; $i < $colorMappingCount; $i++) { $hexColor = $INPUT->str('color_hex_' . $i); $category = $INPUT->str('color_map_' . $i); if (!empty($hexColor) && !empty($category)) { $config['color_mapping'][$hexColor] = $category; } } // Build file content using return format $content = "redirect('Configuration saved successfully!', 'success'); } else { $this->redirect('Error: Could not save configuration file', 'error'); } } private function clearCache() { // Clear DokuWiki cache $cacheDir = DOKU_INC . 'data/cache'; if (is_dir($cacheDir)) { $this->recursiveDelete($cacheDir, false); $this->redirect('Cache cleared successfully!', 'success', 'update'); } else { $this->redirect('Cache directory not found', 'error', 'update'); } } private function recursiveDelete($dir, $deleteRoot = true) { if (!is_dir($dir)) return; $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { $path = $dir . '/' . $file; if (is_dir($path)) { $this->recursiveDelete($path, true); } else { @unlink($path); } } if ($deleteRoot) { @rmdir($dir); } } private function findRecurringEvents() { $dataDir = DOKU_INC . 'data/meta/'; $recurring = []; $allEvents = []; // Track all events to detect patterns // Check root calendar directory first (blank/default namespace) $rootCalendarDir = $dataDir . 'calendar'; if (is_dir($rootCalendarDir)) { foreach (glob($rootCalendarDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $events) { foreach ($events as $event) { // Group by title + namespace (events with same title are likely recurring) $groupKey = strtolower(trim($event['title'])) . '_'; if (!isset($allEvents[$groupKey])) { $allEvents[$groupKey] = [ 'title' => $event['title'], 'namespace' => '', 'dates' => [], 'events' => [] ]; } $allEvents[$groupKey]['dates'][] = $dateKey; $allEvents[$groupKey]['events'][] = $event; } } } } // Scan all namespace directories foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); // Skip the root 'calendar' dir (already processed above) if ($namespace === 'calendar') continue; $calendarDir = $nsDir . '/calendar'; if (!is_dir($calendarDir)) continue; // Scan all calendar files foreach (glob($calendarDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $events) { foreach ($events as $event) { $groupKey = strtolower(trim($event['title'])) . '_' . ($event['namespace'] ?? ''); if (!isset($allEvents[$groupKey])) { $allEvents[$groupKey] = [ 'title' => $event['title'], 'namespace' => $event['namespace'] ?? '', 'dates' => [], 'events' => [] ]; } $allEvents[$groupKey]['dates'][] = $dateKey; $allEvents[$groupKey]['events'][] = $event; } } } } // Analyze patterns - only include if 3+ occurrences foreach ($allEvents as $groupKey => $group) { if (count($group['dates']) >= 3) { // Sort dates sort($group['dates']); // Calculate interval between first and second occurrence $date1 = new DateTime($group['dates'][0]); $date2 = new DateTime($group['dates'][1]); $interval = $date1->diff($date2); // Determine pattern $pattern = 'Custom'; if ($interval->days == 1) { $pattern = 'Daily'; } elseif ($interval->days == 7) { $pattern = 'Weekly'; } elseif ($interval->days >= 14 && $interval->days <= 16) { $pattern = 'Bi-weekly'; } elseif ($interval->days >= 28 && $interval->days <= 31) { $pattern = 'Monthly'; } elseif ($interval->days >= 365 && $interval->days <= 366) { $pattern = 'Yearly'; } // Use first event's ID or create a synthetic one $baseId = isset($group['events'][0]['recurringId']) ? $group['events'][0]['recurringId'] : md5($group['title'] . $group['namespace']); $recurring[] = [ 'baseId' => $baseId, 'title' => $group['title'], 'namespace' => $group['namespace'], 'pattern' => $pattern, 'count' => count($group['dates']), 'firstDate' => $group['dates'][0], 'interval' => $interval->days ]; } } return $recurring; } private function getEventsByNamespace() { $dataDir = DOKU_INC . 'data/meta/'; $result = []; // Check root calendar directory first (blank/default namespace) $rootCalendarDir = $dataDir . 'calendar'; if (is_dir($rootCalendarDir)) { $hasFiles = false; $events = []; foreach (glob($rootCalendarDir . '/*.json') as $file) { $hasFiles = true; $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $eventList) { foreach ($eventList as $event) { $events[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'month' => $month ]; } } } // Add if it has JSON files (even if empty) if ($hasFiles) { $result[''] = ['events' => $events]; } } // Recursively scan all namespace directories including sub-namespaces $this->scanNamespaceRecursive($dataDir, '', $result); // Sort namespaces, but keep '' (default) first uksort($result, function($a, $b) { if ($a === '') return -1; if ($b === '') return 1; return strcmp($a, $b); }); return $result; } private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) { foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { $dirName = basename($nsDir); // Skip the root 'calendar' dir if ($dirName === 'calendar' && empty($parentNamespace)) continue; // Build namespace path $namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName; // Check for calendar directory $calendarDir = $nsDir . '/calendar'; if (is_dir($calendarDir)) { $hasFiles = false; $events = []; // Scan all calendar files foreach (glob($calendarDir . '/*.json') as $file) { $hasFiles = true; $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $eventList) { foreach ($eventList as $event) { $events[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'month' => $month ]; } } } // Add namespace if it has JSON files (even if empty) if ($hasFiles) { $result[$namespace] = ['events' => $events]; } } // Recursively scan sub-directories $this->scanNamespaceRecursive($nsDir . '/', $namespace, $result); } } private function getAllNamespaces() { $dataDir = DOKU_INC . 'data/meta/'; $namespaces = []; // Check root calendar directory first $rootCalendarDir = $dataDir . 'calendar'; if (is_dir($rootCalendarDir)) { $namespaces[] = ''; // Blank/default namespace } // Check all other namespace directories foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); // Skip the root 'calendar' dir (already added as '') if ($namespace === 'calendar') continue; $calendarDir = $nsDir . '/calendar'; if (is_dir($calendarDir)) { $namespaces[] = $namespace; } } return $namespaces; } private function searchEvents($search, $filterNamespace) { $dataDir = DOKU_INC . 'data/meta/'; $results = []; $search = strtolower(trim($search)); foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); $calendarDir = $nsDir . '/calendar'; if (!is_dir($calendarDir)) continue; if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue; foreach (glob($calendarDir . '/*.json') as $file) { $month = basename($file, '.json'); $data = json_decode(file_get_contents($file), true); if (!$data) continue; foreach ($data as $dateKey => $events) { foreach ($events as $event) { if ($search === '' || strpos(strtolower($event['title']), $search) !== false) { $results[] = [ 'id' => $event['id'], 'title' => $event['title'], 'date' => $dateKey, 'startTime' => $event['startTime'] ?? null, 'namespace' => $event['namespace'] ?? '', 'month' => $month ]; } } } } } return $results; } private function deleteRecurringSeries() { global $INPUT; $eventTitle = $INPUT->str('event_title'); $namespace = $INPUT->str('namespace'); // Determine calendar directory if ($namespace === '') { $dataDir = DOKU_INC . 'data/meta/calendar'; } else { $dataDir = DOKU_INC . 'data/meta/' . $namespace . '/calendar'; } $count = 0; if (is_dir($dataDir)) { foreach (glob($dataDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; $modified = false; foreach ($data as $dateKey => $events) { $filtered = []; foreach ($events as $event) { // Match by title (case-insensitive) if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle))) { $count++; $modified = true; } else { $filtered[] = $event; } } $data[$dateKey] = $filtered; } if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } } $this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage'); } private function editRecurringSeries() { global $INPUT; $oldTitle = $INPUT->str('old_title'); $oldNamespace = $INPUT->str('old_namespace'); $newTitle = $INPUT->str('new_title'); $startTime = $INPUT->str('start_time'); $endTime = $INPUT->str('end_time'); $interval = $INPUT->int('interval', 0); $newNamespace = $INPUT->str('new_namespace'); // Use old namespace if new namespace is empty (keep current) if (empty($newNamespace) && !isset($_POST['new_namespace'])) { $newNamespace = $oldNamespace; } // Determine old calendar directory if ($oldNamespace === '') { $oldDataDir = DOKU_INC . 'data/meta/calendar'; } else { $oldDataDir = DOKU_INC . 'data/meta/' . $oldNamespace . '/calendar'; } $count = 0; $eventsToMove = []; if (is_dir($oldDataDir)) { foreach (glob($oldDataDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; $modified = false; foreach ($data as $dateKey => $events) { foreach ($events as $key => $event) { // Match by old title (case-insensitive) if (strtolower(trim($event['title'])) === strtolower(trim($oldTitle))) { // Update the title $data[$dateKey][$key]['title'] = $newTitle; // Update start time if provided if (!empty($startTime)) { $data[$dateKey][$key]['start'] = $startTime; } // Update end time if provided if (!empty($endTime)) { $data[$dateKey][$key]['end'] = $endTime; } // Update namespace $data[$dateKey][$key]['namespace'] = $newNamespace; // If changing interval, calculate new date if ($interval > 0 && $count > 0) { // Get the first event date as base if (empty($firstEventDate)) { $firstEventDate = $dateKey; } // Calculate new date based on interval $newDate = date('Y-m-d', strtotime($firstEventDate . ' +' . ($count * $interval) . ' days')); // Store for moving $eventsToMove[] = [ 'oldDate' => $dateKey, 'newDate' => $newDate, 'event' => $data[$dateKey][$key], 'key' => $key ]; } $count++; $modified = true; } } } if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } // Handle interval changes by moving events to new dates if (!empty($eventsToMove)) { // Remove from old dates first foreach (glob($oldDataDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; $modified = false; foreach ($eventsToMove as $moveData) { $oldMonth = substr($moveData['oldDate'], 0, 7); $fileMonth = basename($file, '.json'); if ($oldMonth === $fileMonth && isset($data[$moveData['oldDate']])) { foreach ($data[$moveData['oldDate']] as $k => $evt) { if ($evt['id'] === $moveData['event']['id']) { unset($data[$moveData['oldDate']][$k]); $data[$moveData['oldDate']] = array_values($data[$moveData['oldDate']]); $modified = true; } } } } if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } // Add to new dates foreach ($eventsToMove as $moveData) { $newMonth = substr($moveData['newDate'], 0, 7); $targetDir = ($newNamespace === '') ? DOKU_INC . 'data/meta/calendar' : DOKU_INC . 'data/meta/' . $newNamespace . '/calendar'; if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } $targetFile = $targetDir . '/' . $newMonth . '.json'; $targetData = file_exists($targetFile) ? json_decode(file_get_contents($targetFile), true) : []; if (!isset($targetData[$moveData['newDate']])) { $targetData[$moveData['newDate']] = []; } $targetData[$moveData['newDate']][] = $moveData['event']; file_put_contents($targetFile, json_encode($targetData, JSON_PRETTY_PRINT)); } } // Handle namespace change without interval change if ($newNamespace !== $oldNamespace && empty($eventsToMove)) { foreach (glob($oldDataDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if (!$data) continue; $month = basename($file, '.json'); $targetDir = ($newNamespace === '') ? DOKU_INC . 'data/meta/calendar' : DOKU_INC . 'data/meta/' . $newNamespace . '/calendar'; if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } $targetFile = $targetDir . '/' . $month . '.json'; $targetData = file_exists($targetFile) ? json_decode(file_get_contents($targetFile), true) : []; $modified = false; foreach ($data as $dateKey => $events) { foreach ($events as $k => $event) { if (isset($event['namespace']) && $event['namespace'] === $newNamespace && strtolower(trim($event['title'])) === strtolower(trim($newTitle))) { // Move this event if (!isset($targetData[$dateKey])) { $targetData[$dateKey] = []; } $targetData[$dateKey][] = $event; unset($data[$dateKey][$k]); $data[$dateKey] = array_values($data[$dateKey]); $modified = true; } } } if ($modified) { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); file_put_contents($targetFile, json_encode($targetData, JSON_PRETTY_PRINT)); } } } } $changes = []; if ($oldTitle !== $newTitle) $changes[] = "title"; if (!empty($startTime) || !empty($endTime)) $changes[] = "time"; if ($interval > 0) $changes[] = "interval"; if ($newNamespace !== $oldNamespace) $changes[] = "namespace"; $changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : ""; $this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage'); } private function moveEvents() { global $INPUT; $events = $INPUT->arr('events'); $targetNamespace = $INPUT->str('target_namespace'); if (empty($events)) { $this->redirect('No events selected', 'error', 'manage'); } $moved = 0; foreach ($events as $eventData) { list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine old file path if ($namespace === '') { $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($oldFile)) continue; $oldData = json_decode(file_get_contents($oldFile), true); if (!$oldData) continue; // Find and remove event from old file $event = null; foreach ($oldData[$date] as $key => $evt) { if ($evt['id'] === $id) { $event = $evt; unset($oldData[$date][$key]); $oldData[$date] = array_values($oldData[$date]); break; } } if (!$event) continue; // Save old file file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); // Update event namespace $event['namespace'] = $targetNamespace; // Determine new file path if ($targetNamespace === '') { $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; $newDir = dirname($newFile); } else { $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; $newDir = dirname($newFile); } if (!is_dir($newDir)) { mkdir($newDir, 0755, true); } $newData = []; if (file_exists($newFile)) { $newData = json_decode(file_get_contents($newFile), true) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); $moved++; } $displayTarget = $targetNamespace ?: '(default)'; $this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage'); } private function moveSingleEvent() { global $INPUT; $eventData = $INPUT->str('event'); $targetNamespace = $INPUT->str('target_namespace'); list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine old file path if ($namespace === '') { $oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($oldFile)) { $this->redirect('Event file not found', 'error', 'manage'); } $oldData = json_decode(file_get_contents($oldFile), true); if (!$oldData) { $this->redirect('Could not read event file', 'error', 'manage'); } // Find and remove event from old file $event = null; foreach ($oldData[$date] as $key => $evt) { if ($evt['id'] === $id) { $event = $evt; unset($oldData[$date][$key]); $oldData[$date] = array_values($oldData[$date]); break; } } if (!$event) { $this->redirect('Event not found', 'error', 'manage'); } // Save old file file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT)); // Update event namespace $event['namespace'] = $targetNamespace; // Determine new file path if ($targetNamespace === '') { $newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; $newDir = dirname($newFile); } else { $newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json'; $newDir = dirname($newFile); } if (!is_dir($newDir)) { mkdir($newDir, 0755, true); } $newData = []; if (file_exists($newFile)) { $newData = json_decode(file_get_contents($newFile), true) ?: []; } if (!isset($newData[$date])) { $newData[$date] = []; } $newData[$date][] = $event; file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT)); $displayTarget = $targetNamespace ?: '(default)'; $this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage'); } private function createNamespace() { global $INPUT; $namespaceName = $INPUT->str('namespace_name'); // Validate namespace name if (empty($namespaceName)) { $this->redirect('Namespace name cannot be empty', 'error', 'manage'); } if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) { $this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage'); } // Convert namespace to directory path $namespacePath = str_replace(':', '/', $namespaceName); $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; // Check if already exists if (is_dir($calendarDir)) { // Check if it has any JSON files $hasFiles = !empty(glob($calendarDir . '/*.json')); if ($hasFiles) { $this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage'); } // If directory exists but empty, continue to create placeholder } // Create the directory if (!is_dir($calendarDir)) { if (!mkdir($calendarDir, 0755, true)) { $this->redirect("Failed to create namespace directory", 'error', 'manage'); } } // Create a placeholder JSON file with an empty structure for current month // This ensures the namespace appears in the list immediately $currentMonth = date('Y-m'); $placeholderFile = $calendarDir . '/' . $currentMonth . '.json'; if (!file_exists($placeholderFile)) { file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT)); } $this->redirect("Created namespace: $namespaceName", 'success', 'manage'); } private function deleteNamespace() { global $INPUT; $namespace = $INPUT->str('namespace'); // Convert namespace to directory path (e.g., "work:projects" โ†’ "work/projects") $namespacePath = str_replace(':', '/', $namespace); // Determine calendar directory if ($namespace === '') { $calendarDir = DOKU_INC . 'data/meta/calendar'; $namespaceDir = null; // Don't delete root } else { $calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar'; $namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath; } // Check if directory exists if (!is_dir($calendarDir)) { // Maybe it was never created or already deleted $this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage'); return; } $filesDeleted = 0; $eventsDeleted = 0; // Delete all calendar JSON files (including empty ones) foreach (glob($calendarDir . '/*.json') as $file) { $data = json_decode(file_get_contents($file), true); if ($data) { foreach ($data as $events) { $eventsDeleted += count($events); } } unlink($file); $filesDeleted++; } // Delete any other files in calendar directory foreach (glob($calendarDir . '/*') as $file) { if (is_file($file)) { unlink($file); } } // Remove the calendar directory if ($namespace !== '') { @rmdir($calendarDir); // Try to remove parent directories if they're empty // This handles nested namespaces like work:projects:alpha $currentDir = dirname($calendarDir); $metaDir = DOKU_INC . 'data/meta'; while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) { if (is_dir($currentDir)) { // Check if directory is empty $contents = scandir($currentDir); $isEmpty = count($contents) === 2; // Only . and .. if ($isEmpty) { @rmdir($currentDir); $currentDir = dirname($currentDir); } else { break; // Directory not empty, stop } } else { break; } } } $displayName = $namespace ?: '(default)'; $this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage'); } private function deleteSelectedEvents() { global $INPUT; $events = $INPUT->arr('events'); if (empty($events)) { $this->redirect('No events selected', 'error', 'manage'); } $deletedCount = 0; foreach ($events as $eventData) { list($id, $namespace, $date, $month) = explode('|', $eventData); // Determine file path if ($namespace === '') { $file = DOKU_INC . 'data/meta/calendar/' . $month . '.json'; } else { $file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json'; } if (!file_exists($file)) continue; $data = json_decode(file_get_contents($file), true); if (!$data) continue; // Find and remove event if (isset($data[$date])) { foreach ($data[$date] as $key => $evt) { if ($evt['id'] === $id) { unset($data[$date][$key]); $data[$date] = array_values($data[$date]); $deletedCount++; break; } } // Remove empty date arrays if (empty($data[$date])) { unset($data[$date]); } // Save file file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } $this->redirect("Deleted $deletedCount event(s)", 'success', 'manage'); } private function getCronStatus() { // Try to read root's crontab first, then current user $output = []; exec('sudo crontab -l 2>/dev/null', $output); // If sudo doesn't work, try current user if (empty($output)) { exec('crontab -l 2>/dev/null', $output); } // Also check system crontab files if (empty($output)) { $cronFiles = [ '/etc/crontab', '/etc/cron.d/calendar', '/var/spool/cron/root', '/var/spool/cron/crontabs/root' ]; foreach ($cronFiles as $file) { if (file_exists($file) && is_readable($file)) { $content = file_get_contents($file); $output = explode("\n", $content); break; } } } // Look for sync_outlook.php in the cron entries foreach ($output as $line) { $line = trim($line); // Skip empty lines and comments if (empty($line) || $line[0] === '#') continue; // Check if line contains sync_outlook.php if (strpos($line, 'sync_outlook.php') !== false) { // Parse cron expression // Format: minute hour day month weekday [user] command $parts = preg_split('/\s+/', $line, 7); if (count($parts) >= 5) { // Determine if this has a user field (system crontab format) $hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5])); $offset = $hasUser ? 1 : 0; $frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]); return [ 'active' => true, 'frequency' => $frequency, 'expression' => implode(' ', array_slice($parts, 0, 5)), 'full_line' => $line ]; } } } return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => '']; } private function parseCronExpression($minute, $hour, $day, $month, $weekday) { // Parse minute field if ($minute === '*') { return 'Runs every minute'; } elseif (strpos($minute, '*/') === 0) { $interval = substr($minute, 2); if ($interval == 1) { return 'Runs every minute'; } elseif ($interval == 5) { return 'Runs every 5 minutes'; } elseif ($interval == 8) { return 'Runs every 8 minutes'; } elseif ($interval == 10) { return 'Runs every 10 minutes'; } elseif ($interval == 15) { return 'Runs every 15 minutes'; } elseif ($interval == 30) { return 'Runs every 30 minutes'; } else { return "Runs every $interval minutes"; } } // Parse hour field if ($hour === '*' && $minute !== '*') { return 'Runs hourly'; } elseif (strpos($hour, '*/') === 0 && $minute !== '*') { $interval = substr($hour, 2); if ($interval == 1) { return 'Runs every hour'; } else { return "Runs every $interval hours"; } } // Parse day field if ($day === '*' && $hour !== '*' && $minute !== '*') { return 'Runs daily'; } // Default return 'Custom schedule'; } private function runSync() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php'; $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; // Remove any existing abort flag if (file_exists($abortFile)) { @unlink($abortFile); } if (!file_exists($syncScript)) { echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]); exit; } // Change to plugin directory $pluginDir = DOKU_PLUGIN . 'calendar'; $logFile = $pluginDir . '/sync.log'; // Ensure log file exists and is writable if (!file_exists($logFile)) { @touch($logFile); @chmod($logFile, 0666); } // Try to log the execution (but don't fail if we can't) if (is_writable($logFile)) { $tz = new DateTimeZone('America/Los_Angeles'); $now = new DateTime('now', $tz); $timestamp = $now->format('Y-m-d H:i:s'); @file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND); } // Find PHP binary - try multiple methods $phpPath = $this->findPhpBinary(); // Build command $command = sprintf( 'cd %s && %s %s 2>&1', escapeshellarg($pluginDir), $phpPath, escapeshellarg(basename($syncScript)) ); // Execute and capture output $output = []; $returnCode = 0; exec($command, $output, $returnCode); // Check if sync completed $lastLines = array_slice($output, -5); $completed = false; foreach ($lastLines as $line) { if (strpos($line, 'Sync Complete') !== false || strpos($line, 'Created:') !== false) { $completed = true; break; } } if ($returnCode === 0 && $completed) { echo json_encode([ 'success' => true, 'message' => 'Sync completed successfully! Check log below.' ]); } elseif ($returnCode === 0) { echo json_encode([ 'success' => true, 'message' => 'Sync started. Check log below for progress.' ]); } else { // Include output for debugging $errorMsg = 'Sync failed with error code: ' . $returnCode; if (!empty($output)) { $errorMsg .= ' | ' . implode(' | ', array_slice($output, -3)); } echo json_encode([ 'success' => false, 'message' => $errorMsg ]); } exit; } } private function stopSync() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $abortFile = DOKU_PLUGIN . 'calendar/.sync_abort'; // Create abort flag file if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) { echo json_encode([ 'success' => true, 'message' => 'Stop signal sent to sync process' ]); } else { echo json_encode([ 'success' => false, 'message' => 'Failed to create abort flag' ]); } exit; } } private function uploadUpdate() { if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) { $this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update'); return; } $uploadedFile = $_FILES['plugin_zip']['tmp_name']; $pluginDir = DOKU_PLUGIN . 'calendar/'; $backupFirst = isset($_POST['backup_first']); // Check if plugin directory is writable if (!is_writable($pluginDir)) { $this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update'); return; } // Check if parent directory is writable (for backup and temp files) if (!is_writable(DOKU_PLUGIN)) { $this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update'); return; } // Verify it's a ZIP file $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $uploadedFile); finfo_close($finfo); if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') { $this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update'); return; } // Create backup if requested if ($backupFirst) { // Get current version $pluginInfo = $pluginDir . 'plugin.info.txt'; $version = 'unknown'; if (file_exists($pluginInfo)) { $info = confToHash($pluginInfo); $version = $info['version'] ?? ($info['date'] ?? 'unknown'); } $backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip'; $backupPath = DOKU_PLUGIN . $backupName; try { $zip = new ZipArchive(); if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) { $this->addDirectoryToZip($zip, $pluginDir, 'calendar/'); $zip->close(); } else { $this->redirect('Failed to create backup ZIP file', 'error', 'update'); return; } } catch (Exception $e) { $this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update'); return; } } // Extract uploaded ZIP $zip = new ZipArchive(); if ($zip->open($uploadedFile) !== TRUE) { $this->redirect('Failed to open ZIP file', 'error', 'update'); return; } // Check if ZIP contains calendar folder $hasCalendarFolder = false; for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (strpos($filename, 'calendar/') === 0) { $hasCalendarFolder = true; break; } } // Extract to temp directory first $tempDir = DOKU_PLUGIN . 'calendar_update_temp/'; if (is_dir($tempDir)) { $this->deleteDirectory($tempDir); } mkdir($tempDir); $zip->extractTo($tempDir); $zip->close(); // Determine source directory if ($hasCalendarFolder) { $sourceDir = $tempDir . 'calendar/'; } else { $sourceDir = $tempDir; } // Preserve configuration files $preserveFiles = ['sync_config.php', 'sync_state.json', 'sync.log']; $preserved = []; foreach ($preserveFiles as $file) { $oldFile = $pluginDir . $file; if (file_exists($oldFile)) { $preserved[$file] = file_get_contents($oldFile); } } // Delete old plugin files (except data files) $this->deleteDirectoryContents($pluginDir, $preserveFiles); // Copy new files $this->recursiveCopy($sourceDir, $pluginDir); // Restore preserved files foreach ($preserved as $file => $content) { file_put_contents($pluginDir . $file, $content); } // Update version and date in plugin.info.txt $pluginInfo = $pluginDir . 'plugin.info.txt'; if (file_exists($pluginInfo)) { $info = confToHash($pluginInfo); // Get new version from uploaded plugin $newVersion = $info['version'] ?? 'unknown'; // Update date to current $info['date'] = date('Y-m-d'); // Write updated info back $lines = []; foreach ($info as $key => $value) { $lines[] = str_pad($key, 8) . ' ' . $value; } file_put_contents($pluginInfo, implode("\n", $lines) . "\n"); } // Cleanup temp directory $this->deleteDirectory($tempDir); $message = 'Plugin updated successfully!'; if ($backupFirst) { $message .= ' Backup saved as: ' . $backupName; } $this->redirect($message, 'success', 'update'); } private function deleteBackup() { global $INPUT; $filename = $INPUT->str('backup_file'); if (empty($filename)) { $this->redirect('No backup file specified', 'error', 'update'); return; } // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { $this->redirect('Invalid backup filename', 'error', 'update'); return; } $backupPath = DOKU_PLUGIN . $filename; if (!file_exists($backupPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } if (@unlink($backupPath)) { $this->redirect('Backup deleted: ' . $filename, 'success', 'update'); } else { $this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update'); } } private function renameBackup() { global $INPUT; $oldName = $INPUT->str('old_name'); $newName = $INPUT->str('new_name'); if (empty($oldName) || empty($newName)) { $this->redirect('Missing filename(s)', 'error', 'update'); return; } // Security: validate filenames if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) { $this->redirect('Invalid filename format', 'error', 'update'); return; } $oldPath = DOKU_PLUGIN . $oldName; $newPath = DOKU_PLUGIN . $newName; if (!file_exists($oldPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } if (file_exists($newPath)) { $this->redirect('A file with the new name already exists', 'error', 'update'); return; } if (@rename($oldPath, $newPath)) { $this->redirect('Backup renamed: ' . $oldName . ' โ†’ ' . $newName, 'success', 'update'); } else { $this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update'); } } private function restoreBackup() { global $INPUT; $filename = $INPUT->str('backup_file'); if (empty($filename)) { $this->redirect('No backup file specified', 'error', 'update'); return; } // Security: only allow files starting with "calendar" and ending with .zip, no directory traversal if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) { $this->redirect('Invalid backup filename', 'error', 'update'); return; } $backupPath = DOKU_PLUGIN . $filename; $pluginDir = DOKU_PLUGIN . 'calendar/'; if (!file_exists($backupPath)) { $this->redirect('Backup file not found', 'error', 'update'); return; } // Check if plugin directory is writable if (!is_writable($pluginDir)) { $this->redirect('Plugin directory is not writable. Please check permissions.', 'error', 'update'); return; } // Extract backup to temp directory $tempDir = DOKU_PLUGIN . 'calendar_restore_temp/'; if (is_dir($tempDir)) { $this->deleteDirectory($tempDir); } mkdir($tempDir); $zip = new ZipArchive(); if ($zip->open($backupPath) !== TRUE) { $this->redirect('Failed to open backup ZIP file', 'error', 'update'); return; } $zip->extractTo($tempDir); $zip->close(); // The backup contains a "calendar/" folder $sourceDir = $tempDir . 'calendar/'; if (!is_dir($sourceDir)) { $this->deleteDirectory($tempDir); $this->redirect('Invalid backup structure', 'error', 'update'); return; } // Delete current plugin directory contents $this->deleteDirectoryContents($pluginDir, []); // Copy backup files to plugin directory $this->recursiveCopy($sourceDir, $pluginDir); // Cleanup temp directory $this->deleteDirectory($tempDir); $this->redirect('Plugin restored from backup: ' . $filename, 'success', 'update'); } private function addDirectoryToZip($zip, $dir, $zipPath = '') { try { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($files as $file) { if (!$file->isDir()) { $filePath = $file->getRealPath(); if ($filePath && is_readable($filePath)) { $relativePath = $zipPath . substr($filePath, strlen($dir)); $zip->addFile($filePath, $relativePath); } } } } catch (Exception $e) { // Log error but continue - some files might not be readable error_log('Calendar plugin backup warning: ' . $e->getMessage()); } } 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) { $dir = opendir($src); @mkdir($dst); while (($file = readdir($dir)) !== false) { if ($file !== '.' && $file !== '..') { if (is_dir($src . '/' . $file)) { $this->recursiveCopy($src . '/' . $file, $dst . '/' . $file); } else { copy($src . '/' . $file, $dst . '/' . $file); } } } closedir($dir); } private function formatBytes($bytes) { if ($bytes >= 1073741824) { return number_format($bytes / 1073741824, 2) . ' GB'; } elseif ($bytes >= 1048576) { return number_format($bytes / 1048576, 2) . ' MB'; } elseif ($bytes >= 1024) { return number_format($bytes / 1024, 2) . ' KB'; } else { return $bytes . ' bytes'; } } private function findPhpBinary() { // Try PHP_BINARY constant first (most reliable if available) if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) { return escapeshellarg(PHP_BINARY); } // Try common PHP binary locations $possiblePaths = [ '/usr/bin/php', '/usr/bin/php8.1', '/usr/bin/php8.2', '/usr/bin/php8.3', '/usr/bin/php7.4', '/usr/local/bin/php', 'php' // Last resort - rely on PATH ]; foreach ($possiblePaths as $path) { // Test if this PHP binary works $testOutput = []; $testReturn = 0; exec($path . ' -v 2>&1', $testOutput, $testReturn); if ($testReturn === 0) { return ($path === 'php') ? 'php' : escapeshellarg($path); } } // Fallback to 'php' and hope it's in PATH return 'php'; } private function redirect($message, $type = 'success', $tab = null) { $url = '?do=admin&page=calendar'; if ($tab) { $url .= '&tab=' . $tab; } $url .= '&msg=' . urlencode($message) . '&msgtype=' . $type; header('Location: ' . $url); exit; } private function getLog() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $logFile = DOKU_PLUGIN . 'calendar/sync.log'; $log = ''; if (file_exists($logFile)) { // Get last 500 lines $lines = file($logFile); if ($lines !== false) { $lines = array_slice($lines, -500); $log = implode('', $lines); } } else { $log = "No log file found. Sync hasn't run yet."; } echo json_encode(['log' => $log]); exit; } } private function exportConfig() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); try { $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; if (!file_exists($configFile)) { echo json_encode([ 'success' => false, 'message' => 'Config file not found' ]); exit; } // Read config file $configContent = file_get_contents($configFile); // Generate encryption key from DokuWiki secret $key = $this->getEncryptionKey(); // Encrypt config $encrypted = $this->encryptData($configContent, $key); echo json_encode([ 'success' => true, 'encrypted' => $encrypted, 'message' => 'Config exported successfully' ]); exit; } catch (Exception $e) { echo json_encode([ 'success' => false, 'message' => $e->getMessage() ]); exit; } } } private function importConfig() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); try { $encrypted = $_POST['encrypted_config'] ?? ''; if (empty($encrypted)) { echo json_encode([ 'success' => false, 'message' => 'No config data provided' ]); exit; } // Generate encryption key from DokuWiki secret $key = $this->getEncryptionKey(); // Decrypt config $configContent = $this->decryptData($encrypted, $key); if ($configContent === false) { echo json_encode([ 'success' => false, 'message' => 'Decryption failed. Invalid key or corrupted file.' ]); exit; } // Validate PHP syntax $valid = @eval('?>' . $configContent); if ($valid === false) { echo json_encode([ 'success' => false, 'message' => 'Invalid config file format' ]); exit; } // Write to config file $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; // Backup existing config if (file_exists($configFile)) { $backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s'); copy($configFile, $backupFile); } // Write new config if (file_put_contents($configFile, $configContent) === false) { echo json_encode([ 'success' => false, 'message' => 'Failed to write config file' ]); exit; } echo json_encode([ 'success' => true, 'message' => 'Config imported successfully' ]); exit; } catch (Exception $e) { echo json_encode([ 'success' => false, 'message' => $e->getMessage() ]); exit; } } } private function getEncryptionKey() { global $conf; // Use DokuWiki's secret as the base for encryption // This ensures the key is unique per installation return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true); } private function encryptData($data, $key) { // Use AES-256-CBC encryption $ivLength = openssl_cipher_iv_length('aes-256-cbc'); $iv = openssl_random_pseudo_bytes($ivLength); $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv); // Combine IV and encrypted data, then base64 encode return base64_encode($iv . $encrypted); } private function decryptData($encryptedData, $key) { // Decode base64 $data = base64_decode($encryptedData); if ($data === false) { return false; } // Extract IV and encrypted content $ivLength = openssl_cipher_iv_length('aes-256-cbc'); $iv = substr($data, 0, $ivLength); $encrypted = substr($data, $ivLength); // Decrypt $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv); return $decrypted; } private function clearLogFile() { global $INPUT; if ($INPUT->str('call') === 'ajax') { header('Content-Type: application/json'); $logFile = DOKU_PLUGIN . 'calendar/sync.log'; if (file_exists($logFile)) { if (file_put_contents($logFile, '')) { echo json_encode(['success' => true]); } else { echo json_encode(['success' => false, 'message' => 'Could not clear log file']); } } else { echo json_encode(['success' => true, 'message' => 'No log file to clear']); } exit; } } private function downloadLog() { $logFile = DOKU_PLUGIN . 'calendar/sync.log'; if (file_exists($logFile)) { header('Content-Type: text/plain'); header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"'); readfile($logFile); exit; } else { echo 'No log file found'; exit; } } private function getEventStatistics() { $stats = [ 'total_events' => 0, 'total_namespaces' => 0, 'total_files' => 0, 'total_recurring' => 0, 'by_namespace' => [], 'last_scan' => '' ]; $metaDir = DOKU_INC . 'data/meta/'; $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; // Check if we have cached stats (less than 5 minutes old) if (file_exists($cacheFile)) { $cacheData = json_decode(file_get_contents($cacheFile), true); if ($cacheData && (time() - $cacheData['timestamp']) < 300) { return $cacheData['stats']; } } // Scan for events $this->scanDirectoryForStats($metaDir, '', $stats); // Count recurring events $recurringEvents = $this->findRecurringEvents(); $stats['total_recurring'] = count($recurringEvents); $stats['total_namespaces'] = count($stats['by_namespace']); $stats['last_scan'] = date('Y-m-d H:i:s'); // Cache the results file_put_contents($cacheFile, json_encode([ 'timestamp' => time(), 'stats' => $stats ])); return $stats; } private function scanDirectoryForStats($dir, $namespace, &$stats) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; // Check if this is a calendar directory if ($item === 'calendar' && is_dir($path)) { $jsonFiles = glob($path . '/*.json'); $eventCount = 0; foreach ($jsonFiles as $file) { $stats['total_files']++; $data = json_decode(file_get_contents($file), true); if ($data) { foreach ($data as $dateEvents) { $eventCount += count($dateEvents); } } } $stats['total_events'] += $eventCount; if ($eventCount > 0) { $stats['by_namespace'][$namespace] = [ 'events' => $eventCount, 'files' => count($jsonFiles) ]; } } elseif (is_dir($path)) { // Recurse into subdirectories $newNamespace = $namespace ? $namespace . ':' . $item : $item; $this->scanDirectoryForStats($path . '/', $newNamespace, $stats); } } } private function rescanEvents() { // Clear the cache to force a rescan $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; if (file_exists($cacheFile)) { unlink($cacheFile); } // 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 $exportData = [ 'export_date' => date('Y-m-d H:i:s'), 'version' => '3.4.6', 'total_events' => 0, 'namespaces' => [] ]; foreach ($allEvents as $namespace => $files) { $exportData['namespaces'][$namespace] = []; foreach ($files as $filename => $events) { $exportData['namespaces'][$namespace][$filename] = $events; foreach ($events as $dateEvents) { $exportData['total_events'] += count($dateEvents); } } } // Send as download header('Content-Type: application/json'); header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"'); echo json_encode($exportData, JSON_PRETTY_PRINT); exit; } private function collectAllEvents($dir, $namespace, &$allEvents) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . $item; // Check if this is a calendar directory if ($item === 'calendar' && is_dir($path)) { $jsonFiles = glob($path . '/*.json'); if (!isset($allEvents[$namespace])) { $allEvents[$namespace] = []; } foreach ($jsonFiles as $file) { $filename = basename($file); $data = json_decode(file_get_contents($file), true); if ($data) { $allEvents[$namespace][$filename] = $data; } } } elseif (is_dir($path)) { // Recurse into subdirectories $newNamespace = $namespace ? $namespace . ':' . $item : $item; $this->collectAllEvents($path . '/', $newNamespace, $allEvents); } } } private function importAllEvents() { global $INPUT; if (!isset($_FILES['import_file'])) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } $file = $_FILES['import_file']; if ($file['error'] !== UPLOAD_ERR_OK) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } // Read and decode the import file $importData = json_decode(file_get_contents($file['tmp_name']), true); if (!$importData || !isset($importData['namespaces'])) { $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error'; header('Location: ' . $redirectUrl, true, 303); exit; } $importedCount = 0; $mergedCount = 0; // Import events foreach ($importData['namespaces'] as $namespace => $files) { $metaDir = DOKU_INC . 'data/meta/'; if ($namespace) { $metaDir .= str_replace(':', '/', $namespace) . '/'; } $calendarDir = $metaDir . 'calendar/'; // Create directory if needed if (!is_dir($calendarDir)) { mkdir($calendarDir, 0755, true); } foreach ($files as $filename => $events) { $targetFile = $calendarDir . $filename; // If file exists, merge events if (file_exists($targetFile)) { $existing = json_decode(file_get_contents($targetFile), true); if ($existing) { foreach ($events as $date => $dateEvents) { if (!isset($existing[$date])) { $existing[$date] = []; } foreach ($dateEvents as $event) { // Check if event with same ID exists $found = false; foreach ($existing[$date] as $existingEvent) { if ($existingEvent['id'] === $event['id']) { $found = true; break; } } if (!$found) { $existing[$date][] = $event; $importedCount++; } else { $mergedCount++; } } } file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT)); } } else { // New file file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT)); foreach ($events as $dateEvents) { $importedCount += count($dateEvents); } } } } // Clear cache $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; if (file_exists($cacheFile)) { unlink($cacheFile); } $message = "Import complete! Imported $importedCount new events"; if ($mergedCount > 0) { $message .= ", skipped $mergedCount duplicates"; } $redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success'; header('Location: ' . $redirectUrl, true, 303); exit; } private function previewCleanup() { global $INPUT; $cleanupType = $INPUT->str('cleanup_type', 'age'); $namespaceFilter = $INPUT->str('namespace_filter', ''); // Debug info $debug = []; $debug['cleanup_type'] = $cleanupType; $debug['namespace_filter'] = $namespaceFilter; $debug['age_value'] = $INPUT->int('age_value', 6); $debug['age_unit'] = $INPUT->str('age_unit', 'months'); $debug['range_start'] = $INPUT->str('range_start', ''); $debug['range_end'] = $INPUT->str('range_end', ''); $debug['delete_completed'] = $INPUT->bool('delete_completed', false); $debug['delete_past'] = $INPUT->bool('delete_past', false); $dataDir = DOKU_INC . 'data/meta/'; $debug['data_dir'] = $dataDir; $debug['data_dir_exists'] = is_dir($dataDir); $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); // Merge with scan debug info if (isset($this->_cleanupDebug)) { $debug = array_merge($debug, $this->_cleanupDebug); } // Return JSON for preview with debug info header('Content-Type: application/json'); echo json_encode([ 'count' => count($eventsToDelete), 'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview 'debug' => $debug ]); exit; } private function cleanupEvents() { global $INPUT; $cleanupType = $INPUT->str('cleanup_type', 'age'); $namespaceFilter = $INPUT->str('namespace_filter', ''); // Create backup first $backupDir = DOKU_PLUGIN . 'calendar/backups/'; if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } $backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip'; $this->createBackup($backupFile); // Find events to delete $eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter); $deletedCount = 0; // Group by file $fileGroups = []; foreach ($eventsToDelete as $evt) { $fileGroups[$evt['file']][] = $evt; } // Delete from each file foreach ($fileGroups as $file => $events) { if (!file_exists($file)) continue; $json = file_get_contents($file); $data = json_decode($json, true); if (!$data) continue; // Remove events foreach ($events as $evt) { if (isset($data[$evt['date']])) { $data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) { return $e['id'] !== $evt['id']; }); // Remove date key if empty if (empty($data[$evt['date']])) { unset($data[$evt['date']]); } $deletedCount++; } } // Save file or delete if empty if (empty($data)) { unlink($file); } else { file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT)); } } // Clear cache $cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache'; if (file_exists($cacheFile)) { unlink($cacheFile); } $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 ]; } } } } } }