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 '
'; echo '๐Ÿ“… ' . $this->getLang('tab_manage') . ''; echo '๐Ÿ“ฆ ' . $this->getLang('tab_update') . ''; echo '๐Ÿ“ง Outlook'; echo '๐Ÿ“† Google'; echo '๐ŸŽจ ' . $this->getLang('tab_themes') . ''; echo '
'; // Render appropriate tab if ($tab === 'config') { $this->renderConfigTab($colors); } elseif ($tab === 'manage') { $this->renderManageTab($colors); } elseif ($tab === 'themes') { $this->renderThemesTab($colors); } elseif ($tab === 'google') { $this->renderGoogleSyncTab($colors); } else { $this->renderUpdateTab($colors); } } private function renderConfigTab($colors = null) { global $INPUT; // Use defaults if not provided if ($colors === null) { $colors = $this->getTemplateColors(); } // 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 '
'; // 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 = $this->getSyncLogPath(); $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'])) { // Check if crontab has >> redirect which will cause duplicate log entries $hasRedirect = (strpos($cronStatus['full_line'], '>>') !== false || strpos($cronStatus['full_line'], '> ') !== false); if ($hasRedirect) { echo '
'; echo 'โš ๏ธ Duplicate log entries: Your crontab has a >> redirect. The sync script logs internally, so this causes duplicate entries. Remove the redirect from your crontab.'; echo '
'; } 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 '

Note: The script logs to ' . $logFile . ' automatically. Do not use >> redirect.

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

๐Ÿ“œ Live Sync Log

'; echo '

Location: ' . $logFile . ' โ€ข 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($colors = null) { global $INPUT; // Use defaults if not provided if ($colors === null) { $colors = $this->getTemplateColors(); } // Show message if present if ($INPUT->has('msg')) { $msg = hsc($INPUT->str('msg')); $type = $INPUT->str('msgtype', 'success'); echo "
"; echo $msg; echo "
"; } echo '

' . $this->getLang('manage_calendar_events') . '

'; // Events Manager Section echo '
'; echo '

๐Ÿ“Š ' . $this->getLang('events_manager') . '

'; echo '

' . $this->getLang('events_manager_desc') . '

'; // Get event statistics $stats = $this->getEventStatistics(); // Statistics display echo '
'; echo '
'; echo '
'; echo '
' . $stats['total_events'] . '
'; echo '
' . $this->getLang('total_events') . '
'; echo '
'; echo '
'; echo '
' . $stats['total_namespaces'] . '
'; echo '
' . $this->getLang('namespaces') . '
'; echo '
'; echo '
'; echo '
' . $stats['total_files'] . '
'; echo '
' . $this->getLang('json_files') . '
'; echo '
'; echo '
'; echo '
' . $stats['total_recurring'] . '
'; echo '
' . $this->getLang('recurring') . '
'; echo '
'; echo '
'; // Last scan time if (!empty($stats['last_scan'])) { echo '
' . $this->getLang('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 '' . $this->getLang('view_breakdown') . ''; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ($stats['by_namespace'] as $ns => $nsStats) { echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . $this->getLang('namespace') . '' . $this->getLang('events_column') . '' . $this->getLang('files_column') . '
' . hsc($ns ?: $this->getLang('default_ns')) . '' . $nsStats['events'] . '' . $nsStats['files'] . '
'; echo '
'; echo '
'; } echo '
'; // Important Namespaces Section $configFile = DOKU_PLUGIN . 'calendar/sync_config.php'; $importantConfig = []; if (file_exists($configFile)) { $importantConfig = include $configFile; } $importantNsValue = isset($importantConfig['important_namespaces']) ? $importantConfig['important_namespaces'] : 'important'; echo '
'; echo '

โญ ' . $this->getLang('important_namespaces') . '

'; echo '

' . $this->getLang('important_ns_desc') . '

'; // Effects description echo '
'; echo '' . $this->getLang('visual_effects') . ':
'; echo 'โ€ข ' . $this->getLang('effect_grid') . '
'; echo 'โ€ข ' . $this->getLang('effect_sidebar') . '
'; echo 'โ€ข ' . $this->getLang('effect_widget') . '
'; echo 'โ€ข ' . $this->getLang('effect_popup'); echo '
'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '

' . $this->getLang('important_ns_hint') . '

'; echo '
'; // Cleanup Events Section echo '
'; echo '

๐Ÿงน ' . $this->getLang('cleanup_old_events') . '

'; echo '

' . $this->getLang('cleanup_desc') . '

'; echo '
'; echo ''; // Compact options layout echo '
'; // Radio buttons in a row echo '
'; echo ''; echo ''; echo ''; echo '
'; // Age options echo '
'; echo '' . $this->getLang('delete_older_than') . ':'; echo ''; echo ''; echo '
'; // Status options echo '
'; echo '' . $this->getLang('delete') . ':'; echo ''; echo ''; echo '
'; // Range options echo '
'; echo '' . $this->getLang('from_date') . ':'; echo ''; echo '' . $this->getLang('to_date') . ':'; echo ''; echo '
'; echo '
'; // Namespace filter - compact echo '
'; echo ''; echo ''; echo '
'; // Action buttons - compact row echo '
'; echo ''; echo ''; echo 'โš ๏ธ ' . $this->getLang('backup_auto') . ''; echo '
'; 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 '
'; // Recurring Events Section echo '
'; echo '
'; echo '

๐Ÿ”„ ' . $this->getLang('recurring_events') . '

'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; $recurringEvents = $this->findRecurringEvents(); echo '
'; $this->renderRecurringTable($recurringEvents, $colors); echo '
'; echo '
'; // Compact Tree-based Namespace Manager echo '
'; echo '

๐Ÿ“ ' . $this->getLang('namespace_explorer') . '

'; echo '

' . $this->getLang('namespace_explorer_desc') . '

'; // Search bar echo '
'; echo ''; echo '
'; $eventsByNamespace = $this->getEventsByNamespace(); // Control bar echo '
'; echo ''; echo '
'; echo ''; echo ''; echo ''; echo '' . $this->getLang('move_to') . ':'; echo ''; echo ''; echo ''; foreach (array_keys($eventsByNamespace) as $ns) { if ($ns !== '') { echo ''; } } echo ''; echo ''; echo ''; echo ''; echo '' . $this->getLang('zero_selected') . ''; echo '
'; // Cleanup status message - displayed prominently after control bar echo '
'; echo '
'; // Event list with checkboxes echo '
'; echo '
'; foreach ($eventsByNamespace as $namespace => $data) { $nsId = 'ns_' . md5($namespace); $events = isset($data['events']) && is_array($data['events']) ? $data['events'] : []; $eventCount = count($events); echo '
'; // Namespace header - ultra compact echo '
'; echo '
'; echo 'โ–ถ'; echo ''; echo '๐Ÿ“ ' . hsc($namespace ?: '(default)') . ''; echo '
'; echo '
'; echo '' . $eventCount . ''; echo ''; echo ''; echo '
'; echo '
'; // Events - ultra compact echo ''; echo '
'; } echo '
'; echo '
'; // Drop zones - ultra compact echo '
'; echo '
๐ŸŽฏ ' . $this->getLang('drop_target') . '
'; echo '
'; foreach (array_keys($eventsByNamespace) as $namespace) { echo '
'; echo '
๐Ÿ“ ' . hsc($namespace ?: $this->getLang('default_ns')) . '
'; echo '
' . $this->getLang('drop_here') . '
'; echo '
'; } echo '
'; echo '
'; echo '
'; // end grid echo '
'; echo '
'; // JavaScript language strings $jsAdminLang = [ // Namespace explorer 'x_selected' => $this->getLang('x_selected'), 'zero_selected' => $this->getLang('zero_selected'), 'cleanup_empty' => $this->getLang('cleanup_empty'), 'default_ns' => $this->getLang('default_ns'), 'no_events_selected' => $this->getLang('no_events_selected'), 'delete_confirm' => $this->getLang('delete_confirm'), 'delete_ns_confirm' => $this->getLang('delete_ns_confirm'), 'scanning' => $this->getLang('scanning'), 'cleaning' => $this->getLang('cleaning'), 'no_empty_ns' => $this->getLang('no_empty_ns'), 'found_items' => $this->getLang('found_items'), 'proceed_cleanup' => $this->getLang('proceed_cleanup'), 'enter_namespace' => $this->getLang('enter_namespace'), 'invalid_namespace' => $this->getLang('invalid_namespace'), 'rename_namespace' => $this->getLang('rename_namespace'), 'delete_recurring_confirm' => $this->getLang('delete_recurring_confirm'), 'no_past_recurring' => $this->getLang('no_past_recurring'), 'found_past_recurring' => $this->getLang('found_past_recurring'), 'counting' => $this->getLang('counting'), 'trimming' => $this->getLang('trimming'), 'trim_confirm' => $this->getLang('trim_confirm'), 'respace_confirm' => $this->getLang('respace_confirm'), 'shift_confirm' => $this->getLang('shift_confirm'), 'trim_all_past' => $this->getLang('trim_all_past'), // Manage recurring dialog 'manage_recurring_title' => $this->getLang('manage_recurring_title'), 'occurrences' => $this->getLang('occurrences'), 'extend_series' => $this->getLang('extend_series'), 'add_occurrences' => $this->getLang('add_occurrences'), 'days_apart' => $this->getLang('days_apart'), 'btn_extend' => $this->getLang('btn_extend'), 'trim_past_events' => $this->getLang('trim_past_events'), 'remove_before' => $this->getLang('remove_before'), 'change_pattern' => $this->getLang('change_pattern'), 'respace_note' => $this->getLang('respace_note'), 'new_interval' => $this->getLang('new_interval'), 'change_start_date' => $this->getLang('change_start_date'), 'shift_note' => $this->getLang('shift_note'), 'current_label' => $this->getLang('current_label'), 'pause_series' => $this->getLang('pause_series'), 'resume_series' => $this->getLang('resume_series'), 'pause_note' => $this->getLang('pause_note'), 'resume_note' => $this->getLang('resume_note'), 'btn_pause' => $this->getLang('btn_pause'), 'btn_resume' => $this->getLang('btn_resume'), 'btn_close' => $this->getLang('btn_close'), 'btn_trim' => $this->getLang('btn_trim'), 'btn_change' => $this->getLang('btn_change'), 'btn_shift' => $this->getLang('btn_shift'), // Interval options 'interval_daily' => $this->getLang('interval_daily'), 'interval_weekly' => $this->getLang('interval_weekly'), 'interval_biweekly' => $this->getLang('interval_biweekly'), 'interval_monthly' => $this->getLang('interval_monthly'), 'interval_quarterly' => $this->getLang('interval_quarterly'), 'interval_yearly' => $this->getLang('interval_yearly'), // Edit recurring dialog 'edit_recurring_title' => $this->getLang('edit_recurring_title'), 'changes_apply_to' => $this->getLang('changes_apply_to'), 'field_title' => $this->getLang('field_title'), 'field_start_time' => $this->getLang('field_start_time'), 'field_end_time' => $this->getLang('field_end_time'), 'field_namespace' => $this->getLang('field_namespace'), 'field_color' => $this->getLang('field_color'), 'recurrence_pattern' => $this->getLang('recurrence_pattern'), 'every' => $this->getLang('every'), 'on_days' => $this->getLang('on_days'), 'monthly_options' => $this->getLang('monthly_options'), 'day_of_month' => $this->getLang('day_of_month'), 'ordinal_weekday' => $this->getLang('ordinal_weekday'), 'btn_save_changes' => $this->getLang('btn_save_changes'), 'btn_cancel' => $this->getLang('btn_cancel'), // Day names 'day_names' => [$this->getLang('day_sun'), $this->getLang('day_mon'), $this->getLang('day_tue'), $this->getLang('day_wed'), $this->getLang('day_thu'), $this->getLang('day_fri'), $this->getLang('day_sat')], 'day_names_full' => [$this->getLang('day_sunday'), $this->getLang('day_monday'), $this->getLang('day_tuesday'), $this->getLang('day_wednesday'), $this->getLang('day_thursday'), $this->getLang('day_friday'), $this->getLang('day_saturday')], // Ordinal labels 'ordinal_first' => $this->getLang('ordinal_first'), 'ordinal_second' => $this->getLang('ordinal_second'), 'ordinal_third' => $this->getLang('ordinal_third'), 'ordinal_fourth' => $this->getLang('ordinal_fourth'), 'ordinal_fifth' => $this->getLang('ordinal_fifth'), 'ordinal_last' => $this->getLang('ordinal_last'), // Recurrence types 'rec_days' => $this->getLang('rec_days'), 'rec_weeks' => $this->getLang('rec_weeks'), 'rec_months' => $this->getLang('rec_months'), 'rec_years' => $this->getLang('rec_years'), ]; // JavaScript echo ''; } private function renderUpdateTab($colors = null) { global $INPUT; // Use defaults if not provided if ($colors === null) { $colors = $this->getTemplateColors(); } echo '

๐Ÿ“ฆ Update Plugin

'; // 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 FIRST (MOVED TO TOP) $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 '

๐Ÿ“‹ Current Version

'; 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 '
'; // Combined upload and notes section (SIDE BY SIDE) echo '
'; // Left side - Upload form (60% width) 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 '
'; // Buttons side by side echo '
'; echo ''; echo ''; // Clear Cache button (next to Upload button) echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo '

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

'; echo '
'; // Right side - Important Notes (40% width) echo '
'; echo '

โš ๏ธ Important Notes

'; echo ''; echo '
'; echo '
'; // End flex container // Changelog section - Timeline viewer echo '
'; echo '

๐Ÿ“‹ Version History

'; $changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md'; if (file_exists($changelogFile)) { $changelog = file_get_contents($changelogFile); // Parse ALL versions into structured data $lines = explode("\n", $changelog); $versions = []; $currentVersion = null; $currentSubsection = ''; foreach ($lines as $line) { $trimmed = trim($line); // Version header (## Version X.X.X or ## Version X.X.X (date) - title) if (preg_match('/^## Version (.+?)(?:\s*\(([^)]+)\))?\s*(?:-\s*(.+))?$/', $trimmed, $matches)) { if ($currentVersion !== null) { $versions[] = $currentVersion; } $currentVersion = [ 'number' => trim($matches[1]), 'date' => isset($matches[2]) ? trim($matches[2]) : '', 'title' => isset($matches[3]) ? trim($matches[3]) : '', 'items' => [] ]; $currentSubsection = ''; } // Subsection header (### Something) elseif ($currentVersion !== null && preg_match('/^### (.+)$/', $trimmed, $matches)) { $currentSubsection = trim($matches[1]); $currentVersion['items'][] = [ 'type' => 'section', 'desc' => $currentSubsection ]; } // Formatted item (- **Type:** description) elseif ($currentVersion !== null && preg_match('/^- \*\*(.+?):\*\*\s*(.+)$/', $trimmed, $matches)) { $currentVersion['items'][] = [ 'type' => trim($matches[1]), 'desc' => trim($matches[2]) ]; } // Plain bullet item (- something) elseif ($currentVersion !== null && preg_match('/^- (.+)$/', $trimmed, $matches)) { $currentVersion['items'][] = [ 'type' => $currentSubsection ?: 'Changed', 'desc' => trim($matches[1]) ]; } } // Don't forget last version if ($currentVersion !== null) { $versions[] = $currentVersion; } $totalVersions = count($versions); $uniqueId = 'changelog_' . substr(md5(microtime()), 0, 6); // Find the index of the currently running version $runningVersion = trim($info['version']); $runningIndex = 0; foreach ($versions as $idx => $ver) { if (trim($ver['number']) === $runningVersion) { $runningIndex = $idx; break; } } if ($totalVersions > 0) { // Timeline navigation bar echo '
'; // Nav controls echo '
'; echo ''; echo '
'; echo '1 of ' . $totalVersions . ''; echo ''; echo '
'; echo ''; echo '
'; // Version cards (one per version, only first visible) foreach ($versions as $i => $ver) { $display = ($i === 0) ? 'block' : 'none'; $isRunning = (trim($ver['number']) === $runningVersion); $cardBorder = $isRunning ? '2px solid #00cc07' : '1px solid ' . $colors['border']; echo '
'; // Version header echo '
'; echo 'v' . hsc($ver['number']) . ''; if ($isRunning) { echo 'RUNNING'; } if ($ver['date']) { echo '' . hsc($ver['date']) . ''; } echo '
'; if ($ver['title']) { echo '
' . hsc($ver['title']) . '
'; } // Change items if (!empty($ver['items'])) { echo '
'; foreach ($ver['items'] as $item) { if ($item['type'] === 'section') { echo '
' . hsc($item['desc']) . '
'; continue; } $color = '#666'; $icon = 'โ€ข'; $t = $item['type']; if ($t === 'Added' || $t === 'New') { $color = '#28a745'; $icon = 'โœจ'; } elseif ($t === 'Fixed' || $t === 'Fix' || $t === 'Bug Fix') { $color = '#dc3545'; $icon = '๐Ÿ”ง'; } elseif ($t === 'Changed' || $t === 'Change') { $color = '#00cc07'; $icon = '๐Ÿ”„'; } elseif ($t === 'Improved' || $t === 'Enhancement') { $color = '#ff9800'; $icon = 'โšก'; } elseif ($t === 'Removed') { $color = '#e91e63'; $icon = '๐Ÿ—‘๏ธ'; } elseif ($t === 'Development' || $t === 'Refactored') { $color = '#6c757d'; $icon = '๐Ÿ› ๏ธ'; } elseif ($t === 'Result') { $color = '#2196f3'; $icon = 'โœ…'; } else { $color = $colors['text']; $icon = 'โ€ข'; } echo '
'; echo '' . $icon . ' ' . hsc($item['type']) . ': '; echo '' . hsc($item['desc']) . ''; echo '
'; } echo '
'; } else { echo '
No detailed changes recorded
'; } echo '
'; } echo '
'; // wrap // JavaScript for navigation echo ''; } else { echo '

No versions found in changelog

'; } } else { echo '

Changelog not available

'; } echo '
'; // Backup list or manual backup section $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'; }); // Always show backup section (even if no backups yet) echo '
'; echo '
'; echo '

๐Ÿ“ Backups

'; // Manual backup button echo '
'; echo ''; echo ''; echo '
'; echo '
'; // Restore instructions note echo '
'; 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 '
'; if (!empty($backups)) { rsort($backups); // Newest first // Bulk action bar echo '
'; echo ''; echo '(0 selected)'; echo ''; echo '
'; echo '
'; 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 ''; } echo '
Backup FileSizeActions
' . hsc($filename) . '' . $size . ''; echo '๐Ÿ“ฅ Download'; echo ''; echo ''; echo '
'; echo '
'; } else { echo '

No backups yet. Click "Create Backup Now" to create your first backup.

'; } 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'); // important_namespaces is managed from the Manage tab, preserve existing value if (!isset($config['important_namespaces'])) { $config['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 $flaggedSeries = []; // Track events with recurring flag by recurringId // Helper to process events from a calendar directory $processCalendarDir = function($calDir, $fallbackNamespace) use (&$allEvents, &$flaggedSeries) { if (!is_dir($calDir)) return; foreach (glob($calDir . '/*.json') as $file) { $data = CalendarFileHandler::readJson($file); if (!$data || !is_array($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']) || empty(trim($event['title']))) continue; $ns = isset($event['namespace']) ? $event['namespace'] : $fallbackNamespace; // If event has recurring flag, group by recurringId if (!empty($event['recurring']) && !empty($event['recurringId'])) { $rid = $event['recurringId']; if (!isset($flaggedSeries[$rid])) { $flaggedSeries[$rid] = [ 'title' => $event['title'], 'namespace' => $ns, 'dates' => [], 'events' => [], // Capture recurrence metadata from first event 'recurrenceType' => $event['recurrenceType'] ?? null, 'recurrenceInterval' => $event['recurrenceInterval'] ?? 1, 'weekDays' => $event['weekDays'] ?? [], 'monthlyType' => $event['monthlyType'] ?? null, 'monthDay' => $event['monthDay'] ?? null, 'ordinalWeek' => $event['ordinalWeek'] ?? null, 'ordinalDay' => $event['ordinalDay'] ?? null, 'time' => $event['time'] ?? null, 'endTime' => $event['endTime'] ?? null, 'color' => $event['color'] ?? null ]; } $flaggedSeries[$rid]['dates'][] = $dateKey; $flaggedSeries[$rid]['events'][] = $event; } // Also group by title+namespace for pattern detection $groupKey = strtolower(trim($event['title'])) . '|' . $ns; if (!isset($allEvents[$groupKey])) { $allEvents[$groupKey] = [ 'title' => $event['title'], 'namespace' => $ns, 'dates' => [], 'events' => [], 'hasFlag' => false, 'time' => $event['time'] ?? null, 'color' => $event['color'] ?? null ]; } $allEvents[$groupKey]['dates'][] = $dateKey; $allEvents[$groupKey]['events'][] = $event; if (!empty($event['recurring'])) { $allEvents[$groupKey]['hasFlag'] = true; } } } } }; // Check root calendar directory (blank/default namespace) $processCalendarDir($dataDir . 'calendar', ''); // Scan all namespace directories (including nested) $this->scanNamespaceDirs($dataDir, $processCalendarDir); // Deduplicate: remove from allEvents groups that are fully covered by flaggedSeries $flaggedTitleNs = []; foreach ($flaggedSeries as $rid => $series) { $key = strtolower(trim($series['title'])) . '|' . $series['namespace']; $flaggedTitleNs[$key] = $rid; } // Build results from flaggedSeries first (known recurring) $seen = []; foreach ($flaggedSeries as $rid => $series) { sort($series['dates']); $dedupDates = array_unique($series['dates']); // Use stored recurrence metadata if available, otherwise detect pattern $pattern = $this->formatRecurrencePattern($series); if (!$pattern) { $pattern = $this->detectRecurrencePattern($dedupDates); } $recurring[] = [ 'baseId' => $rid, 'title' => $series['title'], 'namespace' => $series['namespace'], 'pattern' => $pattern, 'count' => count($dedupDates), 'firstDate' => $dedupDates[0], 'lastDate' => end($dedupDates), 'hasFlag' => true, 'time' => $series['time'], 'endTime' => $series['endTime'], 'color' => $series['color'], 'recurrenceType' => $series['recurrenceType'], 'recurrenceInterval' => $series['recurrenceInterval'], 'weekDays' => $series['weekDays'], 'monthlyType' => $series['monthlyType'], 'monthDay' => $series['monthDay'], 'ordinalWeek' => $series['ordinalWeek'], 'ordinalDay' => $series['ordinalDay'] ]; $seen[strtolower(trim($series['title'])) . '|' . $series['namespace']] = true; } // Add pattern-detected recurring (3+ occurrences, not already in flaggedSeries) foreach ($allEvents as $groupKey => $group) { if (isset($seen[$groupKey])) continue; $dedupDates = array_unique($group['dates']); sort($dedupDates); if (count($dedupDates) < 3) continue; $pattern = $this->detectRecurrencePattern($dedupDates); $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($dedupDates), 'firstDate' => $dedupDates[0], 'lastDate' => end($dedupDates), 'hasFlag' => $group['hasFlag'], 'time' => $group['time'], 'color' => $group['color'], 'recurrenceType' => null, 'recurrenceInterval' => null, 'weekDays' => null, 'monthlyType' => null, 'monthDay' => null, 'ordinalWeek' => null, 'ordinalDay' => null ]; } // Sort by title usort($recurring, function($a, $b) { return strcasecmp($a['title'], $b['title']); }); return $recurring; } /** * Format a human-readable recurrence pattern from stored metadata */ private function formatRecurrencePattern($series) { $type = $series['recurrenceType'] ?? null; $interval = $series['recurrenceInterval'] ?? 1; if (!$type) return null; $result = ''; switch ($type) { case 'daily': if ($interval == 1) { $result = 'Daily'; } else { $result = "Every $interval days"; } break; case 'weekly': $weekDays = $series['weekDays'] ?? []; $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; if ($interval == 1) { $result = 'Weekly'; } elseif ($interval == 2) { $result = 'Bi-weekly'; } else { $result = "Every $interval weeks"; } if (!empty($weekDays) && count($weekDays) < 7) { $dayLabels = array_map(function($d) use ($dayNames) { return $dayNames[$d] ?? ''; }, $weekDays); $result .= ' (' . implode(', ', $dayLabels) . ')'; } break; case 'monthly': $monthlyType = $series['monthlyType'] ?? 'dayOfMonth'; if ($interval == 1) { $prefix = 'Monthly'; } elseif ($interval == 3) { $prefix = 'Quarterly'; } elseif ($interval == 6) { $prefix = 'Semi-annual'; } else { $prefix = "Every $interval months"; } if ($monthlyType === 'dayOfMonth') { $day = $series['monthDay'] ?? '?'; $result = "$prefix (day $day)"; } else { $ordinalNames = [1 => '1st', 2 => '2nd', 3 => '3rd', 4 => '4th', 5 => '5th', -1 => 'Last']; $dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; $ordinal = $ordinalNames[$series['ordinalWeek']] ?? ''; $dayName = $dayNames[$series['ordinalDay']] ?? ''; $result = "$prefix ($ordinal $dayName)"; } break; case 'yearly': if ($interval == 1) { $result = 'Yearly'; } else { $result = "Every $interval years"; } break; default: $result = ucfirst($type); } return $result; } /** * Recursively scan namespace directories for calendar data */ private function scanNamespaceDirs($baseDir, $callback) { foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { $namespace = basename($nsDir); // Skip the root 'calendar' dir (already processed) if ($namespace === 'calendar') continue; $calendarDir = $nsDir . '/calendar'; if (is_dir($calendarDir)) { // Derive namespace from path relative to meta dir $metaDir = DOKU_INC . 'data/meta/'; $relPath = str_replace($metaDir, '', $nsDir); $ns = str_replace('/', ':', trim($relPath, '/')); $callback($calendarDir, $ns); } // Recurse into subdirectories for nested namespaces $this->scanNamespaceDirs($nsDir . '/', $callback); } } /** * Detect recurrence pattern from sorted dates using median interval */ private function detectRecurrencePattern($dates) { if (count($dates) < 2) return 'Single'; // Calculate all intervals between consecutive dates $intervals = []; for ($i = 1; $i < count($dates); $i++) { try { $d1 = new DateTime($dates[$i - 1]); $d2 = new DateTime($dates[$i]); $intervals[] = $d1->diff($d2)->days; } catch (Exception $e) { continue; } } if (empty($intervals)) return 'Custom'; // Check if all intervals are the same (or very close) $uniqueIntervals = array_unique($intervals); $isConsistent = (count($uniqueIntervals) === 1) || (max($intervals) - min($intervals) <= 1); // Allow 1 day variance // Use median interval (more robust than first pair) sort($intervals); $mid = floor(count($intervals) / 2); $median = (count($intervals) % 2 === 0) ? ($intervals[$mid - 1] + $intervals[$mid]) / 2 : $intervals[$mid]; // Check for specific day-based patterns first if ($median <= 1) return 'Daily'; // Check for every N days (2-6 days) if ($median >= 2 && $median <= 6 && $isConsistent) { return 'Every ' . round($median) . ' days'; } // Weekly patterns if ($median >= 6 && $median <= 8) return 'Weekly'; // Check for every N weeks if ($median >= 13 && $median <= 16) return 'Bi-weekly'; if ($median >= 20 && $median <= 23) return 'Every 3 weeks'; // Monthly patterns if ($median >= 27 && $median <= 32) return 'Monthly'; // Check for every N months by looking at month differences if ($median >= 55 && $median <= 65) return 'Every 2 months'; if ($median >= 89 && $median <= 93) return 'Quarterly'; if ($median >= 115 && $median <= 125) return 'Every 4 months'; if ($median >= 175 && $median <= 190) return 'Semi-annual'; // Yearly if ($median >= 363 && $median <= 368) return 'Yearly'; // For other intervals, calculate weeks if appropriate if ($median >= 7 && $median < 28) { $weeks = round($median / 7); if (abs($median - ($weeks * 7)) <= 1) { return "Every $weeks weeks"; } } // For monthly-ish intervals if ($median >= 28 && $median < 365) { $months = round($median / 30); if ($months >= 2 && abs($median - ($months * 30)) <= 3) { return "Every $months months"; } } return 'Every ~' . round($median) . ' days'; } /** * Render the recurring events table HTML */ private function renderRecurringTable($recurringEvents, $colors) { if (empty($recurringEvents)) { echo '

' . $this->getLang('no_recurring_found') . '

'; return; } // Search bar echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; // Pattern translations $patternTranslations = [ 'daily' => $this->getLang('pattern_daily'), 'weekly' => $this->getLang('pattern_weekly'), 'monthly' => $this->getLang('pattern_monthly'), 'yearly' => $this->getLang('pattern_yearly'), ]; foreach ($recurringEvents as $series) { $sourceLabel = $series['hasFlag'] ? '๐Ÿท๏ธ ' . $this->getLang('source_flagged') : '๐Ÿ” ' . $this->getLang('source_detected'); $sourceColor = $series['hasFlag'] ? '#00cc07' : '#ff9800'; // Determine pattern badge class and translate pattern $pattern = strtolower($series['pattern']); $displayPattern = $series['pattern']; if (strpos($pattern, 'daily') !== false || strpos($pattern, 'day') !== false) { $patternClass = 'pattern-daily'; $displayPattern = $this->getLang('pattern_daily'); } elseif (strpos($pattern, 'weekly') !== false || strpos($pattern, 'week') !== false) { $patternClass = 'pattern-weekly'; $displayPattern = $this->getLang('pattern_weekly'); } elseif (strpos($pattern, 'monthly') !== false || strpos($pattern, 'month') !== false || strpos($pattern, 'quarterly') !== false || strpos($pattern, 'semi') !== false) { $patternClass = 'pattern-monthly'; $displayPattern = $this->getLang('pattern_monthly'); } elseif (strpos($pattern, 'yearly') !== false || strpos($pattern, 'year') !== false) { $patternClass = 'pattern-yearly'; $displayPattern = $this->getLang('pattern_yearly'); } else { $patternClass = 'pattern-custom'; $displayPattern = $this->getLang('pattern_custom'); } // Format date range $firstDate = date('M j, Y', strtotime($series['firstDate'])); $lastDate = isset($series['lastDate']) ? date('M j, Y', strtotime($series['lastDate'])) : $firstDate; $dateRange = ($firstDate === $lastDate) ? $firstDate : "$firstDate โ†’ $lastDate"; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . $this->getLang('col_title') . ' โ‡…' . $this->getLang('col_namespace') . ' โ‡…' . $this->getLang('col_pattern') . ' โ‡…' . $this->getLang('col_range') . ' โ‡…' . $this->getLang('col_count') . ' โ‡…' . $this->getLang('col_source') . ' โ‡…' . $this->getLang('col_actions') . '
' . hsc($series['title']) . '' . hsc($series['namespace'] ?: $this->getLang('default_ns')) . '' . hsc($displayPattern) . '' . $dateRange . '' . $series['count'] . '' . $sourceLabel . ''; // 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 '
'; 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 '
โœ“ Google API credentials saved successfully!
'; $status = $googleSync->getStatus(); // Refresh status } } // Handle calendar selection if ($INPUT->str('action') === 'select_google_calendar') { $calendarId = $INPUT->str('selected_calendar'); if ($calendarId) { $googleSync->setCalendarId($calendarId); echo '
โœ“ Calendar selected!
'; } } $accentColor = '#00cc07'; echo '
'; echo '

๐Ÿ“† Google Calendar Sync

'; // Status indicator $statusColor = $status['authenticated'] ? '#28a745' : ($status['configured'] ? '#ffc107' : '#dc3545'); $statusText = $status['authenticated'] ? 'Connected' : ($status['configured'] ? 'Not Authenticated' : 'Not Configured'); $statusIcon = $status['authenticated'] ? 'โœ“' : ($status['configured'] ? 'โš ' : 'โœ•'); echo '
'; echo '
'; echo '' . $statusIcon . ''; echo 'Status: ' . $statusText . ''; echo '
'; if ($status['authenticated']) { echo '

Calendar: ' . htmlspecialchars($status['calendar_id']) . '

'; } echo '
'; // Setup Instructions echo '
'; echo '

Setup Instructions

'; echo '
    '; echo '
  1. Go to Google Cloud Console
  2. '; echo '
  3. Create a new project (or select existing)
  4. '; echo '
  5. Enable the Google Calendar API
  6. '; echo '
  7. Go to Credentials โ†’ Create Credentials โ†’ OAuth 2.0 Client ID
  8. '; echo '
  9. Application type: Web application
  10. '; echo '
  11. Add Authorized redirect URI: ' . DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback
  12. '; echo '
  13. Copy Client ID and Client Secret below
  14. '; echo '
'; echo '
'; // Configuration Form echo '
'; echo '

API Credentials

'; echo '
'; echo ''; echo '
'; echo ''; echo ''; echo 'Leave blank to keep existing value'; echo '
'; echo '
'; echo ''; echo ''; echo 'Leave blank to keep existing value'; echo '
'; echo ''; echo '
'; echo '
'; // Authentication Section if ($status['configured']) { echo '
'; echo '

Authentication

'; if ($status['authenticated']) { echo '

โœ“ Connected to Google Calendar

'; echo ''; } else { echo '

Click below to authorize access to your Google Calendar.

'; echo ''; } echo '
'; } // Calendar Selection (if authenticated) if ($status['authenticated']) { echo '
'; echo '

Select Calendar

'; echo '
Loading calendars...
'; echo '
'; // Import/Export Section echo '
'; echo '

Sync Events

'; echo '
'; // Import section echo '
'; echo '

โฌ‡๏ธ Import from Google

'; echo '

Import events from Google Calendar to DokuWiki.

'; echo '
'; echo ''; echo ''; echo '
'; echo ''; echo '
'; // Export section echo '
'; echo '

โฌ†๏ธ Export to Google

'; echo '

Export events from DokuWiki to Google Calendar.

'; echo '
'; echo ''; echo ''; echo '
'; echo ''; echo '
'; echo '
'; echo '
'; echo '
'; } echo '
'; // End max-width container // JavaScript for Google sync echo ''; } /** * Render Themes tab for sidebar widget theme selection */ private function renderThemesTab($colors = null) { global $INPUT; // Use defaults if not provided if ($colors === null) { $colors = $this->getTemplateColors(); } // Handle theme save if ($INPUT->str('action') === 'save_theme') { $theme = $INPUT->str('theme', 'matrix'); $weekStart = $INPUT->str('week_start', 'monday'); $itineraryCollapsed = $INPUT->str('itinerary_collapsed', 'no'); $showSystemLoad = $INPUT->str('show_system_load', 'yes'); $this->saveSidebarTheme($theme); $this->saveWeekStartDay($weekStart); $this->saveItineraryCollapsed($itineraryCollapsed === 'yes'); $this->saveShowSystemLoad($showSystemLoad === 'yes'); echo '
'; echo 'โœ“ Theme and settings saved successfully! Refresh any page with the sidebar to see changes.'; echo '
'; } $currentTheme = $this->getSidebarTheme(); $currentWeekStart = $this->getWeekStartDay(); $currentItineraryCollapsed = $this->getItineraryCollapsed(); $currentShowSystemLoad = $this->getShowSystemLoad(); echo '

๐ŸŽจ Sidebar Widget Settings

'; echo '

Customize the appearance and behavior of the sidebar calendar widget.

'; echo '
'; echo ''; // Week Start Day Section echo '
'; echo '

๐Ÿ“… Week Start Day

'; echo '

Choose which day the week calendar grid starts with:

'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; // Itinerary Default State Section echo '
'; echo '

๐Ÿ“‹ Itinerary Section

'; echo '

Choose whether the Today/Tomorrow/Important Events sections are expanded or collapsed by default:

'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; // System Load Bars Section echo '
'; echo '

๐Ÿ“Š System Load Bars

'; echo '

Show or hide the CPU/Memory load indicator bars in the event panel:

'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; // Visual Theme Section echo '

๐ŸŽจ Visual Theme

'; // Matrix Theme echo '
'; echo ''; echo '
'; // Purple Theme echo '
'; echo ''; echo '
'; // Professional Blue Theme echo '
'; echo ''; echo '
'; // Pink Bling Theme echo '
'; echo ''; echo '
'; // Wiki Default Theme echo '
'; echo ''; echo '
'; echo ''; echo '
'; } /** * Get current sidebar theme */ private function getSidebarTheme() { $configFile = DOKU_INC . 'data/meta/calendar_theme.txt'; if (file_exists($configFile)) { return trim(file_get_contents($configFile)); } return 'matrix'; // Default } /** * Save sidebar theme */ private function saveSidebarTheme($theme) { $configFile = DOKU_INC . 'data/meta/calendar_theme.txt'; $validThemes = ['matrix', 'purple', 'professional', 'pink', 'wiki']; if (in_array($theme, $validThemes)) { file_put_contents($configFile, $theme); return true; } return false; } /** * Get week start day */ private function getWeekStartDay() { $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt'; if (file_exists($configFile)) { $start = trim(file_get_contents($configFile)); if (in_array($start, ['monday', 'sunday'])) { return $start; } } return 'sunday'; // Default to Sunday (US/Canada standard) } /** * Save week start day */ private function saveWeekStartDay($weekStart) { $configFile = DOKU_INC . 'data/meta/calendar_week_start.txt'; $validStarts = ['monday', 'sunday']; if (in_array($weekStart, $validStarts)) { file_put_contents($configFile, $weekStart); return true; } return false; } /** * Get itinerary collapsed default state */ private function getItineraryCollapsed() { $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt'; if (file_exists($configFile)) { return trim(file_get_contents($configFile)) === 'yes'; } return false; // Default to expanded } /** * Save itinerary collapsed default state */ private function saveItineraryCollapsed($collapsed) { $configFile = DOKU_INC . 'data/meta/calendar_itinerary_collapsed.txt'; file_put_contents($configFile, $collapsed ? 'yes' : 'no'); return true; } /** * Get system load bars visibility setting */ private function getShowSystemLoad() { $configFile = DOKU_INC . 'data/meta/calendar_show_system_load.txt'; if (file_exists($configFile)) { return trim(file_get_contents($configFile)) !== 'no'; } return true; // Default to showing } /** * Save system load bars visibility setting */ private function saveShowSystemLoad($show) { $configFile = DOKU_INC . 'data/meta/calendar_show_system_load.txt'; file_put_contents($configFile, $show ? 'yes' : 'no'); return true; } /** * Get colors from DokuWiki template's style.ini file */ private function getTemplateColors() { global $conf; // Get current template name $template = $conf['template']; // Try multiple possible locations for style.ini $possiblePaths = [ DOKU_INC . 'conf/tpl/' . $template . '/style.ini', DOKU_INC . 'lib/tpl/' . $template . '/style.ini', ]; $styleIni = null; foreach ($possiblePaths as $path) { if (file_exists($path)) { $styleIni = parse_ini_file($path, true); break; } } if (!$styleIni || !isset($styleIni['replacements'])) { // Return defaults return [ 'bg' => '#fff', 'bg_alt' => '#e8e8e8', 'text' => '#333', 'border' => '#ccc', 'link' => '#2b73b7', ]; } $r = $styleIni['replacements']; return [ 'bg' => isset($r['__background__']) ? $r['__background__'] : '#fff', 'bg_alt' => isset($r['__background_alt__']) ? $r['__background_alt__'] : '#e8e8e8', 'text' => isset($r['__text__']) ? $r['__text__'] : '#333', 'border' => isset($r['__border__']) ? $r['__border__'] : '#ccc', 'link' => isset($r['__link__']) ? $r['__link__'] : '#2b73b7', ]; } }