str('action');
if ($action === 'clear_cache') {
$this->clearCache();
} elseif ($action === 'save_config') {
$this->saveConfig();
} elseif ($action === 'delete_recurring_series') {
$this->deleteRecurringSeries();
} elseif ($action === 'edit_recurring_series') {
$this->editRecurringSeries();
} elseif ($action === 'move_events') {
$this->moveEvents();
} elseif ($action === 'move_selected_events') {
$this->moveEvents();
} elseif ($action === 'move_single_event') {
$this->moveSingleEvent();
} elseif ($action === 'delete_selected_events') {
$this->deleteSelectedEvents();
} elseif ($action === 'create_namespace') {
$this->createNamespace();
} elseif ($action === 'delete_namespace') {
$this->deleteNamespace();
} elseif ($action === 'run_sync') {
$this->runSync();
} elseif ($action === 'stop_sync') {
$this->stopSync();
} elseif ($action === 'upload_update') {
$this->uploadUpdate();
} elseif ($action === 'delete_backup') {
$this->deleteBackup();
} elseif ($action === 'rename_backup') {
$this->renameBackup();
} elseif ($action === 'restore_backup') {
$this->restoreBackup();
} elseif ($action === 'export_config') {
$this->exportConfig();
} elseif ($action === 'import_config') {
$this->importConfig();
} elseif ($action === 'get_log') {
$this->getLog();
} elseif ($action === 'clear_log') {
$this->clearLogFile();
} elseif ($action === 'download_log') {
$this->downloadLog();
} elseif ($action === 'rescan_events') {
$this->rescanEvents();
} elseif ($action === 'export_all_events') {
$this->exportAllEvents();
} elseif ($action === 'import_all_events') {
$this->importAllEvents();
} elseif ($action === 'preview_cleanup') {
$this->previewCleanup();
} elseif ($action === 'cleanup_events') {
$this->cleanupEvents();
}
}
public function html() {
global $INPUT;
// Get current tab - default to 'update' (Update Plugin tab)
$tab = $INPUT->str('tab', 'update');
// Tab navigation
echo '
';
// Render appropriate tab
if ($tab === 'config') {
$this->renderConfigTab();
} elseif ($tab === 'manage') {
$this->renderManageTab();
} else {
$this->renderUpdateTab();
}
}
private function renderConfigTab() {
global $INPUT;
// Load current config
$configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
$config = [];
if (file_exists($configFile)) {
$config = include $configFile;
}
// Show message if present
if ($INPUT->has('msg')) {
$msg = hsc($INPUT->str('msg'));
$type = $INPUT->str('msgtype', 'success');
$class = ($type === 'success') ? 'msg success' : 'msg error';
echo "";
echo $msg;
echo "
";
}
echo 'Outlook Sync Configuration ';
// Import/Export buttons
echo '';
echo '๐ค Export Config ';
echo '๐ฅ Import Config ';
echo ' ';
echo ' ';
echo '
';
echo '';
// JavaScript for Import/Export
echo '';
// Sync Controls Section
echo '';
echo '
๐ Sync Controls ';
// Check cron job status
$cronStatus = $this->getCronStatus();
// Check log file permissions
$logFile = DOKU_PLUGIN . 'calendar/sync.log';
$logWritable = is_writable($logFile) || is_writable(dirname($logFile));
echo '
';
echo 'โถ๏ธ Run Sync Now ';
echo 'โน๏ธ Stop Sync ';
if ($cronStatus['active']) {
echo 'โฐ ' . hsc($cronStatus['frequency']) . ' ';
} else {
echo 'โ ๏ธ No cron job detected ';
}
echo ' ';
echo '
';
// Show permission warning if log not writable
if (!$logWritable) {
echo '
';
echo 'โ ๏ธ Log file not writable. Run: chmod 666 ' . $logFile . ' ';
echo '
';
}
// Show debug info if cron detected
if ($cronStatus['active'] && !empty($cronStatus['full_line'])) {
echo '
';
echo 'Show cron details ';
echo '' . hsc($cronStatus['full_line']) . ' ';
echo ' ';
}
if (!$cronStatus['active']) {
echo '
To enable automatic syncing, add to crontab: */30 * * * * cd ' . DOKU_PLUGIN . 'calendar && php sync_outlook.php
';
}
echo '
';
// JavaScript for Run Sync Now
echo '';
// Log Viewer Section - More Compact
echo '';
echo '
๐ Live Sync Log ';
echo '
Updates every 2 seconds
';
// Log viewer container
echo '
';
// Log header - More compact
echo '
';
echo '
sync.log ';
echo '
';
echo 'โธ Pause ';
echo '๐๏ธ Clear ';
echo '๐พ Download ';
echo '
';
echo '
';
// Log content - Reduced height to 250px
echo '
Loading log... ';
echo '
';
echo '
';
// JavaScript for log viewer
echo '';
}
private function renderManageTab() {
global $INPUT;
// Show message if present
if ($INPUT->has('msg')) {
$msg = hsc($INPUT->str('msg'));
$type = $INPUT->str('msgtype', 'success');
echo "";
echo $msg;
echo "
";
}
echo 'Manage Calendar Events ';
// Events Manager Section - NEW!
echo '';
echo '
๐ Events Manager ';
echo '
Scan, export, and import all calendar events across all namespaces.
';
// Get event statistics
$stats = $this->getEventStatistics();
// Statistics display
echo '
';
echo '
';
echo '
';
echo '
' . $stats['total_events'] . '
';
echo '
Total Events
';
echo '
';
echo '
';
echo '
' . $stats['total_namespaces'] . '
';
echo '
Namespaces
';
echo '
';
echo '
';
echo '
' . $stats['total_files'] . '
';
echo '
JSON Files
';
echo '
';
echo '
';
echo '
' . $stats['total_recurring'] . '
';
echo '
Recurring
';
echo '
';
echo '
';
// Last scan time
if (!empty($stats['last_scan'])) {
echo '
Last scanned: ' . hsc($stats['last_scan']) . '
';
}
echo '
';
// Action buttons
echo '
';
// Rescan button
echo '
';
echo ' ';
echo '';
echo '๐ Re-scan Events ';
echo ' ';
echo ' ';
// Export button
echo '';
echo ' ';
echo '';
echo '๐พ Export All Events ';
echo ' ';
echo ' ';
// Import button (with file upload)
echo '';
echo ' ';
echo '';
echo '๐ Import Events ';
echo ' ';
echo ' ';
echo ' ';
echo '';
// Breakdown by namespace
if (!empty($stats['by_namespace'])) {
echo '
';
echo 'View Breakdown by Namespace ';
echo '';
echo '
';
echo '';
echo '';
echo 'Namespace ';
echo 'Events ';
echo 'Files ';
echo ' ';
foreach ($stats['by_namespace'] as $ns => $nsStats) {
echo '';
echo '' . hsc($ns ?: '(default)') . ' ';
echo '' . $nsStats['events'] . ' ';
echo '' . $nsStats['files'] . ' ';
echo ' ';
}
echo '
';
echo '
';
echo ' ';
}
echo '
';
// Cleanup Events Section - Redesigned for compact, sleek look
echo '';
echo '
๐งน Cleanup Old Events ';
echo '
Delete events based on criteria below. Automatic backup created before deletion.
';
echo '
';
echo ' ';
// Compact options layout
echo '';
// Namespace filter - compact
echo '';
echo 'Namespace: ';
echo ' ';
echo '
';
// Action buttons - compact row
echo '';
echo '๐๏ธ Preview ';
echo '๐๏ธ Delete ';
echo 'โ ๏ธ Backup created automatically ';
echo '
';
echo ' ';
// Preview results area
echo '
';
echo '';
echo '
';
// Recurring Events Section
echo '';
echo '
๐ Recurring Events ';
$recurringEvents = $this->findRecurringEvents();
if (empty($recurringEvents)) {
echo '
No recurring events found.
';
} else {
// Search bar
echo '
';
echo ' ';
echo '
';
echo '';
echo '
';
echo '
';
echo '';
echo '';
echo 'Title โ
';
echo 'Namespace โ
';
echo 'Pattern โ
';
echo 'First โ
';
echo 'Count โ
';
echo 'Actions ';
echo ' ';
foreach ($recurringEvents as $series) {
echo '';
echo '' . hsc($series['title']) . ' ';
echo '' . hsc($series['namespace'] ?: '(default)') . ' ';
echo '' . hsc($series['pattern']) . ' ';
echo '' . hsc($series['firstDate']) . ' ';
echo '' . $series['count'] . ' ';
echo '';
echo 'Edit ';
echo 'Del ';
echo ' ';
echo ' ';
}
echo '
';
echo '
';
echo '
Total: ' . count($recurringEvents) . ' series
';
}
echo '
';
// Compact Tree-based Namespace Manager
echo '';
echo '
๐ Namespace Explorer ';
echo '
Select events and move between namespaces. Drag & drop also supported.
';
// Search bar
echo '
';
echo ' ';
echo '
';
$eventsByNamespace = $this->getEventsByNamespace();
// Control bar
echo '
';
echo ' ';
echo '';
echo 'โ All ';
echo 'โ None ';
echo '๐๏ธ Delete ';
echo 'Move to: ';
echo ' ';
echo '';
echo '(default) ';
foreach (array_keys($eventsByNamespace) as $ns) {
if ($ns !== '') {
echo '' . hsc($ns) . ' ';
}
}
echo ' ';
echo 'โก๏ธ Move ';
echo 'โ New Namespace ';
echo '0 selected ';
echo '
';
echo '';
// Event list with checkboxes
echo '
';
echo '
';
foreach ($eventsByNamespace as $namespace => $data) {
$nsId = 'ns_' . md5($namespace);
$eventCount = count($data['events']);
echo '
';
// Namespace header - ultra compact
echo '
';
echo '
';
echo 'โถ ';
echo ' ';
echo '๐ ' . hsc($namespace ?: '(default)') . ' ';
echo '
';
echo '
';
echo '' . $eventCount . ' ';
echo '๐๏ธ ';
echo '
';
echo '
';
// Events - ultra compact
echo '
';
foreach ($data['events'] as $event) {
$eventId = $event['id'] . '|' . $namespace . '|' . $event['date'] . '|' . $event['month'];
$checkId = 'evt_' . md5($eventId);
echo '
';
echo '
';
echo '
';
echo '
' . hsc($event['title']) . '
';
echo '
' . hsc($event['date']) . ($event['startTime'] ? ' โข ' . hsc($event['startTime']) : '') . '
';
echo '
';
echo '
';
}
echo '
';
echo '
';
}
echo '
';
echo '
';
// Drop zones - ultra compact
echo '
';
echo '
๐ฏ Drop Target
';
echo '
';
foreach (array_keys($eventsByNamespace) as $namespace) {
echo '
';
echo '
๐ ' . hsc($namespace ?: '(default)') . '
';
echo '
Drop here
';
echo '
';
}
echo '
';
echo '
';
echo '
'; // end grid
echo ' ';
echo '
';
// JavaScript
echo '';
}
private function renderUpdateTab() {
global $INPUT;
echo '๐ฆ Update Plugin ';
// Clear Cache button
echo '';
echo '
';
echo ' ';
echo ' ';
echo '๐๏ธ Clear Cache ';
echo ' ';
echo '
Clear the DokuWiki cache if changes aren\'t appearing or after updating the plugin.
';
echo '
';
// Show message if present
if ($INPUT->has('msg')) {
$msg = hsc($INPUT->str('msg'));
$type = $INPUT->str('msgtype', 'success');
$class = ($type === 'success') ? 'msg success' : 'msg error';
echo "";
echo $msg;
echo "
";
}
// Show current version
$pluginInfo = DOKU_PLUGIN . 'calendar/plugin.info.txt';
$info = ['version' => 'Unknown', 'date' => 'Unknown', 'name' => 'Calendar Plugin', 'author' => 'Unknown', 'email' => '', 'desc' => ''];
if (file_exists($pluginInfo)) {
$info = array_merge($info, confToHash($pluginInfo));
}
echo '';
echo '
' . hsc($info['name']) . ' ';
echo '
';
echo '
Version: ' . hsc($info['version']) . ' (' . hsc($info['date']) . ')
';
echo '
Author: ' . hsc($info['author']) . ($info['email'] ? ' <' . hsc($info['email']) . '>' : '') . '
';
if ($info['desc']) {
echo '
Description: ' . hsc($info['desc']) . '
';
}
echo '
Location: ' . DOKU_PLUGIN . 'calendar/
';
echo '
';
// Check permissions
$pluginDir = DOKU_PLUGIN . 'calendar/';
$pluginWritable = is_writable($pluginDir);
$parentWritable = is_writable(DOKU_PLUGIN);
echo '
';
if ($pluginWritable && $parentWritable) {
echo '
โ
Permissions: OK - ready to update
';
} else {
echo '
โ Permissions: Issues detected
';
if (!$pluginWritable) {
echo '
Plugin directory not writable
';
}
if (!$parentWritable) {
echo '
Parent directory not writable
';
}
echo '
Fix with: chmod -R 755 ' . DOKU_PLUGIN . 'calendar/
';
echo '
Or: chown -R www-data:www-data ' . DOKU_PLUGIN . 'calendar/
';
}
echo '
';
echo '
';
// Changelog section
echo '';
echo '
๐ Recent Changes ';
$changelogFile = DOKU_PLUGIN . 'calendar/CHANGELOG.md';
if (file_exists($changelogFile)) {
$changelog = file_get_contents($changelogFile);
// Parse markdown and show last 10 versions
$lines = explode("\n", $changelog);
$versionsShown = 0;
$maxVersions = 10;
$inVersion = false;
$changelogHtml = '
';
foreach ($lines as $line) {
$line = trim($line);
// Version header (## Version X.X.X)
if (preg_match('/^## Version (.+)$/', $line, $matches)) {
if ($versionsShown >= $maxVersions) break;
$versionsShown++;
$inVersion = true;
$changelogHtml .= '
';
$changelogHtml .= '
๐ท๏ธ ' . hsc($matches[1]) . '
';
}
// List items (- **Added:** text)
elseif (preg_match('/^- \*\*(.+?):\*\* (.+)$/', $line, $matches)) {
$type = $matches[1];
$description = $matches[2];
// Color-code by type
$color = '#666';
$icon = 'โข';
if ($type === 'Added') { $color = '#28a745'; $icon = 'โจ'; }
elseif ($type === 'Fixed') { $color = '#dc3545'; $icon = '๐ง'; }
elseif ($type === 'Changed') { $color = '#7b1fa2'; $icon = '๐'; }
elseif ($type === 'Improved') { $color = '#ff9800'; $icon = 'โก'; }
elseif ($type === 'Development') { $color = '#6c757d'; $icon = '๐ ๏ธ'; }
$changelogHtml .= '
';
$changelogHtml .= '' . $icon . ' ' . hsc($type) . ': ' . hsc($description) . ' ';
$changelogHtml .= '
';
}
// Close version block on empty line after items
elseif ($inVersion && $line === '' && $versionsShown > 0) {
$changelogHtml .= '
';
$inVersion = false;
}
}
// Close last version if still open
if ($inVersion) {
$changelogHtml .= '
';
}
$changelogHtml .= '
';
echo $changelogHtml;
} else {
echo 'Changelog not available
';
}
echo '';
// Upload form
echo '';
echo '
Upload New Version ';
echo '
Upload a calendar plugin ZIP file to update. Your configuration will be preserved.
';
echo '
';
echo ' ';
echo '';
echo ' ';
echo '
';
echo '';
echo '';
echo ' ';
echo 'Create backup before updating (Recommended) ';
echo ' ';
echo '
';
echo '๐ค Upload & Install ';
echo ' ';
echo '
';
// Warning box
echo '';
echo '
โ ๏ธ Important Notes ';
echo '
';
echo 'This will replace all plugin files ';
echo 'Configuration files (sync_config.php) will be preserved ';
echo 'Event data will not be affected ';
echo 'Backup will be saved to: calendar.backup.vX.X.X.YYYY-MM-DD_HH-MM-SS.zip ';
echo 'Make sure the ZIP file is a valid calendar plugin ';
echo ' ';
echo '
';
// Backup list
$backupDir = DOKU_PLUGIN;
$backups = glob($backupDir . 'calendar*.zip');
// Filter to only show files that look like backups (not the uploaded plugin files)
$backups = array_filter($backups, function($file) {
$name = basename($file);
// Include files that start with "calendar" but exclude files that are just "calendar.zip" (uploaded plugin)
return $name !== 'calendar.zip';
});
if (!empty($backups)) {
rsort($backups); // Newest first
echo '';
echo '
๐ Available Backups ';
echo '
';
echo '
';
echo '';
echo '';
echo 'Backup File ';
echo 'Size ';
echo 'Actions ';
echo ' ';
foreach ($backups as $backup) {
$filename = basename($backup);
$size = $this->formatBytes(filesize($backup));
echo '';
echo '' . hsc($filename) . ' ';
echo '' . $size . ' ';
echo '';
echo '๐ฅ Download ';
echo 'โ๏ธ Rename ';
echo '๐ Restore ';
echo '๐๏ธ Delete ';
echo ' ';
echo ' ';
}
echo '
';
echo '
';
echo '
';
}
echo '';
}
private function saveConfig() {
global $INPUT;
// Load existing config to preserve all settings
$configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
$existingConfig = [];
if (file_exists($configFile)) {
$existingConfig = include $configFile;
}
// Update only the fields from the form - preserve everything else
$config = $existingConfig;
// Update basic fields
$config['tenant_id'] = $INPUT->str('tenant_id');
$config['client_id'] = $INPUT->str('client_id');
$config['client_secret'] = $INPUT->str('client_secret');
$config['user_email'] = $INPUT->str('user_email');
$config['timezone'] = $INPUT->str('timezone', 'America/Los_Angeles');
$config['default_category'] = $INPUT->str('default_category', 'Blue category');
$config['reminder_minutes'] = $INPUT->int('reminder_minutes', 15);
$config['sync_completed_tasks'] = $INPUT->bool('sync_completed_tasks');
$config['delete_outlook_events'] = $INPUT->bool('delete_outlook_events');
$config['sync_all_namespaces'] = $INPUT->bool('sync_all_namespaces');
$config['sync_namespaces'] = $INPUT->arr('sync_namespaces');
$config['important_namespaces'] = $INPUT->str('important_namespaces', 'important');
// Parse category mapping
$config['category_mapping'] = [];
$mappingText = $INPUT->str('category_mapping');
if ($mappingText) {
$lines = explode("\n", $mappingText);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$parts = explode('=', $line, 2);
if (count($parts) === 2) {
$config['category_mapping'][trim($parts[0])] = trim($parts[1]);
}
}
}
// Parse color mapping from dropdown selections
$config['color_mapping'] = [];
$colorMappingCount = $INPUT->int('color_mapping_count', 0);
for ($i = 0; $i < $colorMappingCount; $i++) {
$hexColor = $INPUT->str('color_hex_' . $i);
$category = $INPUT->str('color_map_' . $i);
if (!empty($hexColor) && !empty($category)) {
$config['color_mapping'][$hexColor] = $category;
}
}
// Build file content using return format
$content = "redirect('Configuration saved successfully!', 'success');
} else {
$this->redirect('Error: Could not save configuration file', 'error');
}
}
private function clearCache() {
// Clear DokuWiki cache
$cacheDir = DOKU_INC . 'data/cache';
if (is_dir($cacheDir)) {
$this->recursiveDelete($cacheDir, false);
$this->redirect('Cache cleared successfully!', 'success', 'update');
} else {
$this->redirect('Cache directory not found', 'error', 'update');
}
}
private function recursiveDelete($dir, $deleteRoot = true) {
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), array('.', '..'));
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->recursiveDelete($path, true);
} else {
@unlink($path);
}
}
if ($deleteRoot) {
@rmdir($dir);
}
}
private function findRecurringEvents() {
$dataDir = DOKU_INC . 'data/meta/';
$recurring = [];
$allEvents = []; // Track all events to detect patterns
// Check root calendar directory first (blank/default namespace)
$rootCalendarDir = $dataDir . 'calendar';
if (is_dir($rootCalendarDir)) {
foreach (glob($rootCalendarDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
foreach ($data as $dateKey => $events) {
foreach ($events as $event) {
// Group by title + namespace (events with same title are likely recurring)
$groupKey = strtolower(trim($event['title'])) . '_';
if (!isset($allEvents[$groupKey])) {
$allEvents[$groupKey] = [
'title' => $event['title'],
'namespace' => '',
'dates' => [],
'events' => []
];
}
$allEvents[$groupKey]['dates'][] = $dateKey;
$allEvents[$groupKey]['events'][] = $event;
}
}
}
}
// Scan all namespace directories
foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
$namespace = basename($nsDir);
// Skip the root 'calendar' dir (already processed above)
if ($namespace === 'calendar') continue;
$calendarDir = $nsDir . '/calendar';
if (!is_dir($calendarDir)) continue;
// Scan all calendar files
foreach (glob($calendarDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
foreach ($data as $dateKey => $events) {
foreach ($events as $event) {
$groupKey = strtolower(trim($event['title'])) . '_' . ($event['namespace'] ?? '');
if (!isset($allEvents[$groupKey])) {
$allEvents[$groupKey] = [
'title' => $event['title'],
'namespace' => $event['namespace'] ?? '',
'dates' => [],
'events' => []
];
}
$allEvents[$groupKey]['dates'][] = $dateKey;
$allEvents[$groupKey]['events'][] = $event;
}
}
}
}
// Analyze patterns - only include if 3+ occurrences
foreach ($allEvents as $groupKey => $group) {
if (count($group['dates']) >= 3) {
// Sort dates
sort($group['dates']);
// Calculate interval between first and second occurrence
$date1 = new DateTime($group['dates'][0]);
$date2 = new DateTime($group['dates'][1]);
$interval = $date1->diff($date2);
// Determine pattern
$pattern = 'Custom';
if ($interval->days == 1) {
$pattern = 'Daily';
} elseif ($interval->days == 7) {
$pattern = 'Weekly';
} elseif ($interval->days >= 14 && $interval->days <= 16) {
$pattern = 'Bi-weekly';
} elseif ($interval->days >= 28 && $interval->days <= 31) {
$pattern = 'Monthly';
} elseif ($interval->days >= 365 && $interval->days <= 366) {
$pattern = 'Yearly';
}
// Use first event's ID or create a synthetic one
$baseId = isset($group['events'][0]['recurringId'])
? $group['events'][0]['recurringId']
: md5($group['title'] . $group['namespace']);
$recurring[] = [
'baseId' => $baseId,
'title' => $group['title'],
'namespace' => $group['namespace'],
'pattern' => $pattern,
'count' => count($group['dates']),
'firstDate' => $group['dates'][0],
'interval' => $interval->days
];
}
}
return $recurring;
}
private function getEventsByNamespace() {
$dataDir = DOKU_INC . 'data/meta/';
$result = [];
// Check root calendar directory first (blank/default namespace)
$rootCalendarDir = $dataDir . 'calendar';
if (is_dir($rootCalendarDir)) {
$hasFiles = false;
$events = [];
foreach (glob($rootCalendarDir . '/*.json') as $file) {
$hasFiles = true;
$month = basename($file, '.json');
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
foreach ($data as $dateKey => $eventList) {
foreach ($eventList as $event) {
$events[] = [
'id' => $event['id'],
'title' => $event['title'],
'date' => $dateKey,
'startTime' => $event['startTime'] ?? null,
'month' => $month
];
}
}
}
// Add if it has JSON files (even if empty)
if ($hasFiles) {
$result[''] = ['events' => $events];
}
}
// Recursively scan all namespace directories including sub-namespaces
$this->scanNamespaceRecursive($dataDir, '', $result);
// Sort namespaces, but keep '' (default) first
uksort($result, function($a, $b) {
if ($a === '') return -1;
if ($b === '') return 1;
return strcmp($a, $b);
});
return $result;
}
private function scanNamespaceRecursive($baseDir, $parentNamespace, &$result) {
foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) {
$dirName = basename($nsDir);
// Skip the root 'calendar' dir
if ($dirName === 'calendar' && empty($parentNamespace)) continue;
// Build namespace path
$namespace = empty($parentNamespace) ? $dirName : $parentNamespace . ':' . $dirName;
// Check for calendar directory
$calendarDir = $nsDir . '/calendar';
if (is_dir($calendarDir)) {
$hasFiles = false;
$events = [];
// Scan all calendar files
foreach (glob($calendarDir . '/*.json') as $file) {
$hasFiles = true;
$month = basename($file, '.json');
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
foreach ($data as $dateKey => $eventList) {
foreach ($eventList as $event) {
$events[] = [
'id' => $event['id'],
'title' => $event['title'],
'date' => $dateKey,
'startTime' => $event['startTime'] ?? null,
'month' => $month
];
}
}
}
// Add namespace if it has JSON files (even if empty)
if ($hasFiles) {
$result[$namespace] = ['events' => $events];
}
}
// Recursively scan sub-directories
$this->scanNamespaceRecursive($nsDir . '/', $namespace, $result);
}
}
private function getAllNamespaces() {
$dataDir = DOKU_INC . 'data/meta/';
$namespaces = [];
// Check root calendar directory first
$rootCalendarDir = $dataDir . 'calendar';
if (is_dir($rootCalendarDir)) {
$namespaces[] = ''; // Blank/default namespace
}
// Check all other namespace directories
foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
$namespace = basename($nsDir);
// Skip the root 'calendar' dir (already added as '')
if ($namespace === 'calendar') continue;
$calendarDir = $nsDir . '/calendar';
if (is_dir($calendarDir)) {
$namespaces[] = $namespace;
}
}
return $namespaces;
}
private function searchEvents($search, $filterNamespace) {
$dataDir = DOKU_INC . 'data/meta/';
$results = [];
$search = strtolower(trim($search));
foreach (glob($dataDir . '*', GLOB_ONLYDIR) as $nsDir) {
$namespace = basename($nsDir);
$calendarDir = $nsDir . '/calendar';
if (!is_dir($calendarDir)) continue;
if ($filterNamespace !== '' && $namespace !== $filterNamespace) continue;
foreach (glob($calendarDir . '/*.json') as $file) {
$month = basename($file, '.json');
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
foreach ($data as $dateKey => $events) {
foreach ($events as $event) {
if ($search === '' || strpos(strtolower($event['title']), $search) !== false) {
$results[] = [
'id' => $event['id'],
'title' => $event['title'],
'date' => $dateKey,
'startTime' => $event['startTime'] ?? null,
'namespace' => $event['namespace'] ?? '',
'month' => $month
];
}
}
}
}
}
return $results;
}
private function deleteRecurringSeries() {
global $INPUT;
$eventTitle = $INPUT->str('event_title');
$namespace = $INPUT->str('namespace');
// Determine calendar directory
if ($namespace === '') {
$dataDir = DOKU_INC . 'data/meta/calendar';
} else {
$dataDir = DOKU_INC . 'data/meta/' . $namespace . '/calendar';
}
$count = 0;
if (is_dir($dataDir)) {
foreach (glob($dataDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
$modified = false;
foreach ($data as $dateKey => $events) {
$filtered = [];
foreach ($events as $event) {
// Match by title (case-insensitive)
if (strtolower(trim($event['title'])) === strtolower(trim($eventTitle))) {
$count++;
$modified = true;
} else {
$filtered[] = $event;
}
}
$data[$dateKey] = $filtered;
}
if ($modified) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
}
$this->redirect("Deleted $count occurrences of recurring event: " . $eventTitle, 'success', 'manage');
}
private function editRecurringSeries() {
global $INPUT;
$oldTitle = $INPUT->str('old_title');
$oldNamespace = $INPUT->str('old_namespace');
$newTitle = $INPUT->str('new_title');
$startTime = $INPUT->str('start_time');
$endTime = $INPUT->str('end_time');
$interval = $INPUT->int('interval', 0);
$newNamespace = $INPUT->str('new_namespace');
// Use old namespace if new namespace is empty (keep current)
if (empty($newNamespace) && !isset($_POST['new_namespace'])) {
$newNamespace = $oldNamespace;
}
// Determine old calendar directory
if ($oldNamespace === '') {
$oldDataDir = DOKU_INC . 'data/meta/calendar';
} else {
$oldDataDir = DOKU_INC . 'data/meta/' . $oldNamespace . '/calendar';
}
$count = 0;
$eventsToMove = [];
if (is_dir($oldDataDir)) {
foreach (glob($oldDataDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
$modified = false;
foreach ($data as $dateKey => $events) {
foreach ($events as $key => $event) {
// Match by old title (case-insensitive)
if (strtolower(trim($event['title'])) === strtolower(trim($oldTitle))) {
// Update the title
$data[$dateKey][$key]['title'] = $newTitle;
// Update start time if provided
if (!empty($startTime)) {
$data[$dateKey][$key]['start'] = $startTime;
}
// Update end time if provided
if (!empty($endTime)) {
$data[$dateKey][$key]['end'] = $endTime;
}
// Update namespace
$data[$dateKey][$key]['namespace'] = $newNamespace;
// If changing interval, calculate new date
if ($interval > 0 && $count > 0) {
// Get the first event date as base
if (empty($firstEventDate)) {
$firstEventDate = $dateKey;
}
// Calculate new date based on interval
$newDate = date('Y-m-d', strtotime($firstEventDate . ' +' . ($count * $interval) . ' days'));
// Store for moving
$eventsToMove[] = [
'oldDate' => $dateKey,
'newDate' => $newDate,
'event' => $data[$dateKey][$key],
'key' => $key
];
}
$count++;
$modified = true;
}
}
}
if ($modified) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
// Handle interval changes by moving events to new dates
if (!empty($eventsToMove)) {
// Remove from old dates first
foreach (glob($oldDataDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
$modified = false;
foreach ($eventsToMove as $moveData) {
$oldMonth = substr($moveData['oldDate'], 0, 7);
$fileMonth = basename($file, '.json');
if ($oldMonth === $fileMonth && isset($data[$moveData['oldDate']])) {
foreach ($data[$moveData['oldDate']] as $k => $evt) {
if ($evt['id'] === $moveData['event']['id']) {
unset($data[$moveData['oldDate']][$k]);
$data[$moveData['oldDate']] = array_values($data[$moveData['oldDate']]);
$modified = true;
}
}
}
}
if ($modified) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
// Add to new dates
foreach ($eventsToMove as $moveData) {
$newMonth = substr($moveData['newDate'], 0, 7);
$targetDir = ($newNamespace === '') ? DOKU_INC . 'data/meta/calendar' : DOKU_INC . 'data/meta/' . $newNamespace . '/calendar';
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$targetFile = $targetDir . '/' . $newMonth . '.json';
$targetData = file_exists($targetFile) ? json_decode(file_get_contents($targetFile), true) : [];
if (!isset($targetData[$moveData['newDate']])) {
$targetData[$moveData['newDate']] = [];
}
$targetData[$moveData['newDate']][] = $moveData['event'];
file_put_contents($targetFile, json_encode($targetData, JSON_PRETTY_PRINT));
}
}
// Handle namespace change without interval change
if ($newNamespace !== $oldNamespace && empty($eventsToMove)) {
foreach (glob($oldDataDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
$month = basename($file, '.json');
$targetDir = ($newNamespace === '') ? DOKU_INC . 'data/meta/calendar' : DOKU_INC . 'data/meta/' . $newNamespace . '/calendar';
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$targetFile = $targetDir . '/' . $month . '.json';
$targetData = file_exists($targetFile) ? json_decode(file_get_contents($targetFile), true) : [];
$modified = false;
foreach ($data as $dateKey => $events) {
foreach ($events as $k => $event) {
if (isset($event['namespace']) && $event['namespace'] === $newNamespace &&
strtolower(trim($event['title'])) === strtolower(trim($newTitle))) {
// Move this event
if (!isset($targetData[$dateKey])) {
$targetData[$dateKey] = [];
}
$targetData[$dateKey][] = $event;
unset($data[$dateKey][$k]);
$data[$dateKey] = array_values($data[$dateKey]);
$modified = true;
}
}
}
if ($modified) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
file_put_contents($targetFile, json_encode($targetData, JSON_PRETTY_PRINT));
}
}
}
}
$changes = [];
if ($oldTitle !== $newTitle) $changes[] = "title";
if (!empty($startTime) || !empty($endTime)) $changes[] = "time";
if ($interval > 0) $changes[] = "interval";
if ($newNamespace !== $oldNamespace) $changes[] = "namespace";
$changeStr = !empty($changes) ? " (" . implode(", ", $changes) . ")" : "";
$this->redirect("Updated $count occurrences of recurring event$changeStr", 'success', 'manage');
}
private function moveEvents() {
global $INPUT;
$events = $INPUT->arr('events');
$targetNamespace = $INPUT->str('target_namespace');
if (empty($events)) {
$this->redirect('No events selected', 'error', 'manage');
}
$moved = 0;
foreach ($events as $eventData) {
list($id, $namespace, $date, $month) = explode('|', $eventData);
// Determine old file path
if ($namespace === '') {
$oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
} else {
$oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
}
if (!file_exists($oldFile)) continue;
$oldData = json_decode(file_get_contents($oldFile), true);
if (!$oldData) continue;
// Find and remove event from old file
$event = null;
foreach ($oldData[$date] as $key => $evt) {
if ($evt['id'] === $id) {
$event = $evt;
unset($oldData[$date][$key]);
$oldData[$date] = array_values($oldData[$date]);
break;
}
}
if (!$event) continue;
// Save old file
file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
// Update event namespace
$event['namespace'] = $targetNamespace;
// Determine new file path
if ($targetNamespace === '') {
$newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
$newDir = dirname($newFile);
} else {
$newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
$newDir = dirname($newFile);
}
if (!is_dir($newDir)) {
mkdir($newDir, 0755, true);
}
$newData = [];
if (file_exists($newFile)) {
$newData = json_decode(file_get_contents($newFile), true) ?: [];
}
if (!isset($newData[$date])) {
$newData[$date] = [];
}
$newData[$date][] = $event;
file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
$moved++;
}
$displayTarget = $targetNamespace ?: '(default)';
$this->redirect("Moved $moved event(s) to namespace: " . $displayTarget, 'success', 'manage');
}
private function moveSingleEvent() {
global $INPUT;
$eventData = $INPUT->str('event');
$targetNamespace = $INPUT->str('target_namespace');
list($id, $namespace, $date, $month) = explode('|', $eventData);
// Determine old file path
if ($namespace === '') {
$oldFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
} else {
$oldFile = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
}
if (!file_exists($oldFile)) {
$this->redirect('Event file not found', 'error', 'manage');
}
$oldData = json_decode(file_get_contents($oldFile), true);
if (!$oldData) {
$this->redirect('Could not read event file', 'error', 'manage');
}
// Find and remove event from old file
$event = null;
foreach ($oldData[$date] as $key => $evt) {
if ($evt['id'] === $id) {
$event = $evt;
unset($oldData[$date][$key]);
$oldData[$date] = array_values($oldData[$date]);
break;
}
}
if (!$event) {
$this->redirect('Event not found', 'error', 'manage');
}
// Save old file
file_put_contents($oldFile, json_encode($oldData, JSON_PRETTY_PRINT));
// Update event namespace
$event['namespace'] = $targetNamespace;
// Determine new file path
if ($targetNamespace === '') {
$newFile = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
$newDir = dirname($newFile);
} else {
$newFile = DOKU_INC . 'data/meta/' . $targetNamespace . '/calendar/' . $month . '.json';
$newDir = dirname($newFile);
}
if (!is_dir($newDir)) {
mkdir($newDir, 0755, true);
}
$newData = [];
if (file_exists($newFile)) {
$newData = json_decode(file_get_contents($newFile), true) ?: [];
}
if (!isset($newData[$date])) {
$newData[$date] = [];
}
$newData[$date][] = $event;
file_put_contents($newFile, json_encode($newData, JSON_PRETTY_PRINT));
$displayTarget = $targetNamespace ?: '(default)';
$this->redirect('Moved "' . $event['title'] . '" to ' . $displayTarget, 'success', 'manage');
}
private function createNamespace() {
global $INPUT;
$namespaceName = $INPUT->str('namespace_name');
// Validate namespace name
if (empty($namespaceName)) {
$this->redirect('Namespace name cannot be empty', 'error', 'manage');
}
if (!preg_match('/^[a-zA-Z0-9_:-]+$/', $namespaceName)) {
$this->redirect('Invalid namespace name. Use only letters, numbers, underscore, hyphen, and colon.', 'error', 'manage');
}
// Convert namespace to directory path
$namespacePath = str_replace(':', '/', $namespaceName);
$calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
// Check if already exists
if (is_dir($calendarDir)) {
// Check if it has any JSON files
$hasFiles = !empty(glob($calendarDir . '/*.json'));
if ($hasFiles) {
$this->redirect("Namespace '$namespaceName' already exists with events", 'info', 'manage');
}
// If directory exists but empty, continue to create placeholder
}
// Create the directory
if (!is_dir($calendarDir)) {
if (!mkdir($calendarDir, 0755, true)) {
$this->redirect("Failed to create namespace directory", 'error', 'manage');
}
}
// Create a placeholder JSON file with an empty structure for current month
// This ensures the namespace appears in the list immediately
$currentMonth = date('Y-m');
$placeholderFile = $calendarDir . '/' . $currentMonth . '.json';
if (!file_exists($placeholderFile)) {
file_put_contents($placeholderFile, json_encode([], JSON_PRETTY_PRINT));
}
$this->redirect("Created namespace: $namespaceName", 'success', 'manage');
}
private function deleteNamespace() {
global $INPUT;
$namespace = $INPUT->str('namespace');
// Convert namespace to directory path (e.g., "work:projects" โ "work/projects")
$namespacePath = str_replace(':', '/', $namespace);
// Determine calendar directory
if ($namespace === '') {
$calendarDir = DOKU_INC . 'data/meta/calendar';
$namespaceDir = null; // Don't delete root
} else {
$calendarDir = DOKU_INC . 'data/meta/' . $namespacePath . '/calendar';
$namespaceDir = DOKU_INC . 'data/meta/' . $namespacePath;
}
// Check if directory exists
if (!is_dir($calendarDir)) {
// Maybe it was never created or already deleted
$this->redirect("Namespace directory not found: $calendarDir", 'error', 'manage');
return;
}
$filesDeleted = 0;
$eventsDeleted = 0;
// Delete all calendar JSON files (including empty ones)
foreach (glob($calendarDir . '/*.json') as $file) {
$data = json_decode(file_get_contents($file), true);
if ($data) {
foreach ($data as $events) {
$eventsDeleted += count($events);
}
}
unlink($file);
$filesDeleted++;
}
// Delete any other files in calendar directory
foreach (glob($calendarDir . '/*') as $file) {
if (is_file($file)) {
unlink($file);
}
}
// Remove the calendar directory
if ($namespace !== '') {
@rmdir($calendarDir);
// Try to remove parent directories if they're empty
// This handles nested namespaces like work:projects:alpha
$currentDir = dirname($calendarDir);
$metaDir = DOKU_INC . 'data/meta';
while ($currentDir !== $metaDir && $currentDir !== dirname($metaDir)) {
if (is_dir($currentDir)) {
// Check if directory is empty
$contents = scandir($currentDir);
$isEmpty = count($contents) === 2; // Only . and ..
if ($isEmpty) {
@rmdir($currentDir);
$currentDir = dirname($currentDir);
} else {
break; // Directory not empty, stop
}
} else {
break;
}
}
}
$displayName = $namespace ?: '(default)';
$this->redirect("Deleted namespace '$displayName': $eventsDeleted events in $filesDeleted files", 'success', 'manage');
}
private function deleteSelectedEvents() {
global $INPUT;
$events = $INPUT->arr('events');
if (empty($events)) {
$this->redirect('No events selected', 'error', 'manage');
}
$deletedCount = 0;
foreach ($events as $eventData) {
list($id, $namespace, $date, $month) = explode('|', $eventData);
// Determine file path
if ($namespace === '') {
$file = DOKU_INC . 'data/meta/calendar/' . $month . '.json';
} else {
$file = DOKU_INC . 'data/meta/' . $namespace . '/calendar/' . $month . '.json';
}
if (!file_exists($file)) continue;
$data = json_decode(file_get_contents($file), true);
if (!$data) continue;
// Find and remove event
if (isset($data[$date])) {
foreach ($data[$date] as $key => $evt) {
if ($evt['id'] === $id) {
unset($data[$date][$key]);
$data[$date] = array_values($data[$date]);
$deletedCount++;
break;
}
}
// Remove empty date arrays
if (empty($data[$date])) {
unset($data[$date]);
}
// Save file
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
$this->redirect("Deleted $deletedCount event(s)", 'success', 'manage');
}
private function getCronStatus() {
// Try to read root's crontab first, then current user
$output = [];
exec('sudo crontab -l 2>/dev/null', $output);
// If sudo doesn't work, try current user
if (empty($output)) {
exec('crontab -l 2>/dev/null', $output);
}
// Also check system crontab files
if (empty($output)) {
$cronFiles = [
'/etc/crontab',
'/etc/cron.d/calendar',
'/var/spool/cron/root',
'/var/spool/cron/crontabs/root'
];
foreach ($cronFiles as $file) {
if (file_exists($file) && is_readable($file)) {
$content = file_get_contents($file);
$output = explode("\n", $content);
break;
}
}
}
// Look for sync_outlook.php in the cron entries
foreach ($output as $line) {
$line = trim($line);
// Skip empty lines and comments
if (empty($line) || $line[0] === '#') continue;
// Check if line contains sync_outlook.php
if (strpos($line, 'sync_outlook.php') !== false) {
// Parse cron expression
// Format: minute hour day month weekday [user] command
$parts = preg_split('/\s+/', $line, 7);
if (count($parts) >= 5) {
// Determine if this has a user field (system crontab format)
$hasUser = (count($parts) >= 6 && !preg_match('/^[\/\*]/', $parts[5]));
$offset = $hasUser ? 1 : 0;
$frequency = $this->parseCronExpression($parts[0], $parts[1], $parts[2], $parts[3], $parts[4]);
return [
'active' => true,
'frequency' => $frequency,
'expression' => implode(' ', array_slice($parts, 0, 5)),
'full_line' => $line
];
}
}
}
return ['active' => false, 'frequency' => '', 'expression' => '', 'full_line' => ''];
}
private function parseCronExpression($minute, $hour, $day, $month, $weekday) {
// Parse minute field
if ($minute === '*') {
return 'Runs every minute';
} elseif (strpos($minute, '*/') === 0) {
$interval = substr($minute, 2);
if ($interval == 1) {
return 'Runs every minute';
} elseif ($interval == 5) {
return 'Runs every 5 minutes';
} elseif ($interval == 8) {
return 'Runs every 8 minutes';
} elseif ($interval == 10) {
return 'Runs every 10 minutes';
} elseif ($interval == 15) {
return 'Runs every 15 minutes';
} elseif ($interval == 30) {
return 'Runs every 30 minutes';
} else {
return "Runs every $interval minutes";
}
}
// Parse hour field
if ($hour === '*' && $minute !== '*') {
return 'Runs hourly';
} elseif (strpos($hour, '*/') === 0 && $minute !== '*') {
$interval = substr($hour, 2);
if ($interval == 1) {
return 'Runs every hour';
} else {
return "Runs every $interval hours";
}
}
// Parse day field
if ($day === '*' && $hour !== '*' && $minute !== '*') {
return 'Runs daily';
}
// Default
return 'Custom schedule';
}
private function runSync() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
$syncScript = DOKU_PLUGIN . 'calendar/sync_outlook.php';
$abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
// Remove any existing abort flag
if (file_exists($abortFile)) {
@unlink($abortFile);
}
if (!file_exists($syncScript)) {
echo json_encode(['success' => false, 'message' => 'Sync script not found at: ' . $syncScript]);
exit;
}
// Change to plugin directory
$pluginDir = DOKU_PLUGIN . 'calendar';
$logFile = $pluginDir . '/sync.log';
// Ensure log file exists and is writable
if (!file_exists($logFile)) {
@touch($logFile);
@chmod($logFile, 0666);
}
// Try to log the execution (but don't fail if we can't)
if (is_writable($logFile)) {
$tz = new DateTimeZone('America/Los_Angeles');
$now = new DateTime('now', $tz);
$timestamp = $now->format('Y-m-d H:i:s');
@file_put_contents($logFile, "[$timestamp] [ADMIN] Manual sync triggered via admin panel\n", FILE_APPEND);
}
// Find PHP binary - try multiple methods
$phpPath = $this->findPhpBinary();
// Build command
$command = sprintf(
'cd %s && %s %s 2>&1',
escapeshellarg($pluginDir),
$phpPath,
escapeshellarg(basename($syncScript))
);
// Execute and capture output
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
// Check if sync completed
$lastLines = array_slice($output, -5);
$completed = false;
foreach ($lastLines as $line) {
if (strpos($line, 'Sync Complete') !== false || strpos($line, 'Created:') !== false) {
$completed = true;
break;
}
}
if ($returnCode === 0 && $completed) {
echo json_encode([
'success' => true,
'message' => 'Sync completed successfully! Check log below.'
]);
} elseif ($returnCode === 0) {
echo json_encode([
'success' => true,
'message' => 'Sync started. Check log below for progress.'
]);
} else {
// Include output for debugging
$errorMsg = 'Sync failed with error code: ' . $returnCode;
if (!empty($output)) {
$errorMsg .= ' | ' . implode(' | ', array_slice($output, -3));
}
echo json_encode([
'success' => false,
'message' => $errorMsg
]);
}
exit;
}
}
private function stopSync() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
$abortFile = DOKU_PLUGIN . 'calendar/.sync_abort';
// Create abort flag file
if (file_put_contents($abortFile, date('Y-m-d H:i:s')) !== false) {
echo json_encode([
'success' => true,
'message' => 'Stop signal sent to sync process'
]);
} else {
echo json_encode([
'success' => false,
'message' => 'Failed to create abort flag'
]);
}
exit;
}
}
private function uploadUpdate() {
if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) {
$this->redirect('Upload failed: ' . ($_FILES['plugin_zip']['error'] ?? 'No file uploaded'), 'error', 'update');
return;
}
$uploadedFile = $_FILES['plugin_zip']['tmp_name'];
$pluginDir = DOKU_PLUGIN . 'calendar/';
$backupFirst = isset($_POST['backup_first']);
// Check if plugin directory is writable
if (!is_writable($pluginDir)) {
$this->redirect('Plugin directory is not writable. Please check permissions: ' . $pluginDir, 'error', 'update');
return;
}
// Check if parent directory is writable (for backup and temp files)
if (!is_writable(DOKU_PLUGIN)) {
$this->redirect('Plugin parent directory is not writable. Please check permissions: ' . DOKU_PLUGIN, 'error', 'update');
return;
}
// Verify it's a ZIP file
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $uploadedFile);
finfo_close($finfo);
if ($mimeType !== 'application/zip' && $mimeType !== 'application/x-zip-compressed') {
$this->redirect('Invalid file type. Please upload a ZIP file.', 'error', 'update');
return;
}
// Create backup if requested
if ($backupFirst) {
// Get current version
$pluginInfo = $pluginDir . 'plugin.info.txt';
$version = 'unknown';
if (file_exists($pluginInfo)) {
$info = confToHash($pluginInfo);
$version = $info['version'] ?? ($info['date'] ?? 'unknown');
}
$backupName = 'calendar.backup.v' . $version . '.' . date('Y-m-d_H-i-s') . '.zip';
$backupPath = DOKU_PLUGIN . $backupName;
try {
$zip = new ZipArchive();
if ($zip->open($backupPath, ZipArchive::CREATE) === TRUE) {
$this->addDirectoryToZip($zip, $pluginDir, 'calendar/');
$zip->close();
} else {
$this->redirect('Failed to create backup ZIP file', 'error', 'update');
return;
}
} catch (Exception $e) {
$this->redirect('Backup failed: ' . $e->getMessage(), 'error', 'update');
return;
}
}
// Extract uploaded ZIP
$zip = new ZipArchive();
if ($zip->open($uploadedFile) !== TRUE) {
$this->redirect('Failed to open ZIP file', 'error', 'update');
return;
}
// Check if ZIP contains calendar folder
$hasCalendarFolder = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
if (strpos($filename, 'calendar/') === 0) {
$hasCalendarFolder = true;
break;
}
}
// Extract to temp directory first
$tempDir = DOKU_PLUGIN . 'calendar_update_temp/';
if (is_dir($tempDir)) {
$this->deleteDirectory($tempDir);
}
mkdir($tempDir);
$zip->extractTo($tempDir);
$zip->close();
// Determine source directory
if ($hasCalendarFolder) {
$sourceDir = $tempDir . 'calendar/';
} else {
$sourceDir = $tempDir;
}
// Preserve configuration files
$preserveFiles = ['sync_config.php', 'sync_state.json', 'sync.log'];
$preserved = [];
foreach ($preserveFiles as $file) {
$oldFile = $pluginDir . $file;
if (file_exists($oldFile)) {
$preserved[$file] = file_get_contents($oldFile);
}
}
// Delete old plugin files (except data files)
$this->deleteDirectoryContents($pluginDir, $preserveFiles);
// Copy new files
$this->recursiveCopy($sourceDir, $pluginDir);
// Restore preserved files
foreach ($preserved as $file => $content) {
file_put_contents($pluginDir . $file, $content);
}
// Update version and date in plugin.info.txt
$pluginInfo = $pluginDir . 'plugin.info.txt';
if (file_exists($pluginInfo)) {
$info = confToHash($pluginInfo);
// Get new version from uploaded plugin
$newVersion = $info['version'] ?? 'unknown';
// Update date to current
$info['date'] = date('Y-m-d');
// Write updated info back
$lines = [];
foreach ($info as $key => $value) {
$lines[] = str_pad($key, 8) . ' ' . $value;
}
file_put_contents($pluginInfo, implode("\n", $lines) . "\n");
}
// Cleanup temp directory
$this->deleteDirectory($tempDir);
$message = 'Plugin updated successfully!';
if ($backupFirst) {
$message .= ' Backup saved as: ' . $backupName;
}
$this->redirect($message, 'success', 'update');
}
private function deleteBackup() {
global $INPUT;
$filename = $INPUT->str('backup_file');
if (empty($filename)) {
$this->redirect('No backup file specified', 'error', 'update');
return;
}
// Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
$this->redirect('Invalid backup filename', 'error', 'update');
return;
}
$backupPath = DOKU_PLUGIN . $filename;
if (!file_exists($backupPath)) {
$this->redirect('Backup file not found', 'error', 'update');
return;
}
if (@unlink($backupPath)) {
$this->redirect('Backup deleted: ' . $filename, 'success', 'update');
} else {
$this->redirect('Failed to delete backup. Check file permissions.', 'error', 'update');
}
}
private function renameBackup() {
global $INPUT;
$oldName = $INPUT->str('old_name');
$newName = $INPUT->str('new_name');
if (empty($oldName) || empty($newName)) {
$this->redirect('Missing filename(s)', 'error', 'update');
return;
}
// Security: validate filenames
if (!preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $oldName) || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $newName)) {
$this->redirect('Invalid filename format', 'error', 'update');
return;
}
$oldPath = DOKU_PLUGIN . $oldName;
$newPath = DOKU_PLUGIN . $newName;
if (!file_exists($oldPath)) {
$this->redirect('Backup file not found', 'error', 'update');
return;
}
if (file_exists($newPath)) {
$this->redirect('A file with the new name already exists', 'error', 'update');
return;
}
if (@rename($oldPath, $newPath)) {
$this->redirect('Backup renamed: ' . $oldName . ' โ ' . $newName, 'success', 'update');
} else {
$this->redirect('Failed to rename backup. Check file permissions.', 'error', 'update');
}
}
private function restoreBackup() {
global $INPUT;
$filename = $INPUT->str('backup_file');
if (empty($filename)) {
$this->redirect('No backup file specified', 'error', 'update');
return;
}
// Security: only allow files starting with "calendar" and ending with .zip, no directory traversal
if (!preg_match('/^calendar[a-zA-Z0-9._-]*\.zip$/', $filename)) {
$this->redirect('Invalid backup filename', 'error', 'update');
return;
}
$backupPath = DOKU_PLUGIN . $filename;
$pluginDir = DOKU_PLUGIN . 'calendar/';
if (!file_exists($backupPath)) {
$this->redirect('Backup file not found', 'error', 'update');
return;
}
// Check if plugin directory is writable
if (!is_writable($pluginDir)) {
$this->redirect('Plugin directory is not writable. Please check permissions.', 'error', 'update');
return;
}
// Extract backup to temp directory
$tempDir = DOKU_PLUGIN . 'calendar_restore_temp/';
if (is_dir($tempDir)) {
$this->deleteDirectory($tempDir);
}
mkdir($tempDir);
$zip = new ZipArchive();
if ($zip->open($backupPath) !== TRUE) {
$this->redirect('Failed to open backup ZIP file', 'error', 'update');
return;
}
$zip->extractTo($tempDir);
$zip->close();
// The backup contains a "calendar/" folder
$sourceDir = $tempDir . 'calendar/';
if (!is_dir($sourceDir)) {
$this->deleteDirectory($tempDir);
$this->redirect('Invalid backup structure', 'error', 'update');
return;
}
// Delete current plugin directory contents
$this->deleteDirectoryContents($pluginDir, []);
// Copy backup files to plugin directory
$this->recursiveCopy($sourceDir, $pluginDir);
// Cleanup temp directory
$this->deleteDirectory($tempDir);
$this->redirect('Plugin restored from backup: ' . $filename, 'success', 'update');
}
private function addDirectoryToZip($zip, $dir, $zipPath = '') {
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
if (!$file->isDir()) {
$filePath = $file->getRealPath();
if ($filePath && is_readable($filePath)) {
$relativePath = $zipPath . substr($filePath, strlen($dir));
$zip->addFile($filePath, $relativePath);
}
}
}
} catch (Exception $e) {
// Log error but continue - some files might not be readable
error_log('Calendar plugin backup warning: ' . $e->getMessage());
}
}
private function deleteDirectory($dir) {
if (!is_dir($dir)) return;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getRealPath());
} else {
@unlink($file->getRealPath());
}
}
@rmdir($dir);
} catch (Exception $e) {
error_log('Calendar plugin delete directory error: ' . $e->getMessage());
}
}
private function deleteDirectoryContents($dir, $preserve = []) {
if (!is_dir($dir)) return;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
if (in_array($item, $preserve)) continue;
$path = $dir . $item;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
}
private function recursiveCopy($src, $dst) {
$dir = opendir($src);
@mkdir($dst);
while (($file = readdir($dir)) !== false) {
if ($file !== '.' && $file !== '..') {
if (is_dir($src . '/' . $file)) {
$this->recursiveCopy($src . '/' . $file, $dst . '/' . $file);
} else {
copy($src . '/' . $file, $dst . '/' . $file);
}
}
}
closedir($dir);
}
private function formatBytes($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
private function findPhpBinary() {
// Try PHP_BINARY constant first (most reliable if available)
if (defined('PHP_BINARY') && !empty(PHP_BINARY) && is_executable(PHP_BINARY)) {
return escapeshellarg(PHP_BINARY);
}
// Try common PHP binary locations
$possiblePaths = [
'/usr/bin/php',
'/usr/bin/php8.1',
'/usr/bin/php8.2',
'/usr/bin/php8.3',
'/usr/bin/php7.4',
'/usr/local/bin/php',
'php' // Last resort - rely on PATH
];
foreach ($possiblePaths as $path) {
// Test if this PHP binary works
$testOutput = [];
$testReturn = 0;
exec($path . ' -v 2>&1', $testOutput, $testReturn);
if ($testReturn === 0) {
return ($path === 'php') ? 'php' : escapeshellarg($path);
}
}
// Fallback to 'php' and hope it's in PATH
return 'php';
}
private function redirect($message, $type = 'success', $tab = null) {
$url = '?do=admin&page=calendar';
if ($tab) {
$url .= '&tab=' . $tab;
}
$url .= '&msg=' . urlencode($message) . '&msgtype=' . $type;
header('Location: ' . $url);
exit;
}
private function getLog() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
$logFile = DOKU_PLUGIN . 'calendar/sync.log';
$log = '';
if (file_exists($logFile)) {
// Get last 500 lines
$lines = file($logFile);
if ($lines !== false) {
$lines = array_slice($lines, -500);
$log = implode('', $lines);
}
} else {
$log = "No log file found. Sync hasn't run yet.";
}
echo json_encode(['log' => $log]);
exit;
}
}
private function exportConfig() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
try {
$configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
if (!file_exists($configFile)) {
echo json_encode([
'success' => false,
'message' => 'Config file not found'
]);
exit;
}
// Read config file
$configContent = file_get_contents($configFile);
// Generate encryption key from DokuWiki secret
$key = $this->getEncryptionKey();
// Encrypt config
$encrypted = $this->encryptData($configContent, $key);
echo json_encode([
'success' => true,
'encrypted' => $encrypted,
'message' => 'Config exported successfully'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
}
}
}
private function importConfig() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
try {
$encrypted = $_POST['encrypted_config'] ?? '';
if (empty($encrypted)) {
echo json_encode([
'success' => false,
'message' => 'No config data provided'
]);
exit;
}
// Generate encryption key from DokuWiki secret
$key = $this->getEncryptionKey();
// Decrypt config
$configContent = $this->decryptData($encrypted, $key);
if ($configContent === false) {
echo json_encode([
'success' => false,
'message' => 'Decryption failed. Invalid key or corrupted file.'
]);
exit;
}
// Validate PHP syntax
$valid = @eval('?>' . $configContent);
if ($valid === false) {
echo json_encode([
'success' => false,
'message' => 'Invalid config file format'
]);
exit;
}
// Write to config file
$configFile = DOKU_PLUGIN . 'calendar/sync_config.php';
// Backup existing config
if (file_exists($configFile)) {
$backupFile = $configFile . '.backup.' . date('Y-m-d_H-i-s');
copy($configFile, $backupFile);
}
// Write new config
if (file_put_contents($configFile, $configContent) === false) {
echo json_encode([
'success' => false,
'message' => 'Failed to write config file'
]);
exit;
}
echo json_encode([
'success' => true,
'message' => 'Config imported successfully'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
}
}
}
private function getEncryptionKey() {
global $conf;
// Use DokuWiki's secret as the base for encryption
// This ensures the key is unique per installation
return hash('sha256', $conf['secret'] . 'calendar_config_encryption', true);
}
private function encryptData($data, $key) {
// Use AES-256-CBC encryption
$ivLength = openssl_cipher_iv_length('aes-256-cbc');
$iv = openssl_random_pseudo_bytes($ivLength);
$encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv);
// Combine IV and encrypted data, then base64 encode
return base64_encode($iv . $encrypted);
}
private function decryptData($encryptedData, $key) {
// Decode base64
$data = base64_decode($encryptedData);
if ($data === false) {
return false;
}
// Extract IV and encrypted content
$ivLength = openssl_cipher_iv_length('aes-256-cbc');
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
// Decrypt
$decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
return $decrypted;
}
private function clearLogFile() {
global $INPUT;
if ($INPUT->str('call') === 'ajax') {
header('Content-Type: application/json');
$logFile = DOKU_PLUGIN . 'calendar/sync.log';
if (file_exists($logFile)) {
if (file_put_contents($logFile, '')) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'message' => 'Could not clear log file']);
}
} else {
echo json_encode(['success' => true, 'message' => 'No log file to clear']);
}
exit;
}
}
private function downloadLog() {
$logFile = DOKU_PLUGIN . 'calendar/sync.log';
if (file_exists($logFile)) {
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="calendar-sync-' . date('Y-m-d-His') . '.log"');
readfile($logFile);
exit;
} else {
echo 'No log file found';
exit;
}
}
private function getEventStatistics() {
$stats = [
'total_events' => 0,
'total_namespaces' => 0,
'total_files' => 0,
'total_recurring' => 0,
'by_namespace' => [],
'last_scan' => ''
];
$metaDir = DOKU_INC . 'data/meta/';
$cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
// Check if we have cached stats (less than 5 minutes old)
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData && (time() - $cacheData['timestamp']) < 300) {
return $cacheData['stats'];
}
}
// Scan for events
$this->scanDirectoryForStats($metaDir, '', $stats);
// Count recurring events
$recurringEvents = $this->findRecurringEvents();
$stats['total_recurring'] = count($recurringEvents);
$stats['total_namespaces'] = count($stats['by_namespace']);
$stats['last_scan'] = date('Y-m-d H:i:s');
// Cache the results
file_put_contents($cacheFile, json_encode([
'timestamp' => time(),
'stats' => $stats
]));
return $stats;
}
private function scanDirectoryForStats($dir, $namespace, &$stats) {
if (!is_dir($dir)) return;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . $item;
// Check if this is a calendar directory
if ($item === 'calendar' && is_dir($path)) {
$jsonFiles = glob($path . '/*.json');
$eventCount = 0;
foreach ($jsonFiles as $file) {
$stats['total_files']++;
$data = json_decode(file_get_contents($file), true);
if ($data) {
foreach ($data as $dateEvents) {
$eventCount += count($dateEvents);
}
}
}
$stats['total_events'] += $eventCount;
if ($eventCount > 0) {
$stats['by_namespace'][$namespace] = [
'events' => $eventCount,
'files' => count($jsonFiles)
];
}
} elseif (is_dir($path)) {
// Recurse into subdirectories
$newNamespace = $namespace ? $namespace . ':' . $item : $item;
$this->scanDirectoryForStats($path . '/', $newNamespace, $stats);
}
}
}
private function rescanEvents() {
// Clear the cache to force a rescan
$cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
if (file_exists($cacheFile)) {
unlink($cacheFile);
}
// Get fresh statistics
$stats = $this->getEventStatistics();
// Build absolute redirect URL
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Events rescanned! Found ' . $stats['total_events'] . ' events in ' . $stats['total_namespaces'] . ' namespaces.') . '&msgtype=success';
// Redirect with success message using absolute URL
header('Location: ' . $redirectUrl, true, 303);
exit;
}
private function exportAllEvents() {
$metaDir = DOKU_INC . 'data/meta/';
$allEvents = [];
// Collect all events
$this->collectAllEvents($metaDir, '', $allEvents);
// Create export package
$exportData = [
'export_date' => date('Y-m-d H:i:s'),
'version' => '3.4.6',
'total_events' => 0,
'namespaces' => []
];
foreach ($allEvents as $namespace => $files) {
$exportData['namespaces'][$namespace] = [];
foreach ($files as $filename => $events) {
$exportData['namespaces'][$namespace][$filename] = $events;
foreach ($events as $dateEvents) {
$exportData['total_events'] += count($dateEvents);
}
}
}
// Send as download
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="calendar-events-export-' . date('Y-m-d-His') . '.json"');
echo json_encode($exportData, JSON_PRETTY_PRINT);
exit;
}
private function collectAllEvents($dir, $namespace, &$allEvents) {
if (!is_dir($dir)) return;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . $item;
// Check if this is a calendar directory
if ($item === 'calendar' && is_dir($path)) {
$jsonFiles = glob($path . '/*.json');
if (!isset($allEvents[$namespace])) {
$allEvents[$namespace] = [];
}
foreach ($jsonFiles as $file) {
$filename = basename($file);
$data = json_decode(file_get_contents($file), true);
if ($data) {
$allEvents[$namespace][$filename] = $data;
}
}
} elseif (is_dir($path)) {
// Recurse into subdirectories
$newNamespace = $namespace ? $namespace . ':' . $item : $item;
$this->collectAllEvents($path . '/', $newNamespace, $allEvents);
}
}
}
private function importAllEvents() {
global $INPUT;
if (!isset($_FILES['import_file'])) {
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('No file uploaded') . '&msgtype=error';
header('Location: ' . $redirectUrl, true, 303);
exit;
}
$file = $_FILES['import_file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Upload error: ' . $file['error']) . '&msgtype=error';
header('Location: ' . $redirectUrl, true, 303);
exit;
}
// Read and decode the import file
$importData = json_decode(file_get_contents($file['tmp_name']), true);
if (!$importData || !isset($importData['namespaces'])) {
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode('Invalid import file format') . '&msgtype=error';
header('Location: ' . $redirectUrl, true, 303);
exit;
}
$importedCount = 0;
$mergedCount = 0;
// Import events
foreach ($importData['namespaces'] as $namespace => $files) {
$metaDir = DOKU_INC . 'data/meta/';
if ($namespace) {
$metaDir .= str_replace(':', '/', $namespace) . '/';
}
$calendarDir = $metaDir . 'calendar/';
// Create directory if needed
if (!is_dir($calendarDir)) {
mkdir($calendarDir, 0755, true);
}
foreach ($files as $filename => $events) {
$targetFile = $calendarDir . $filename;
// If file exists, merge events
if (file_exists($targetFile)) {
$existing = json_decode(file_get_contents($targetFile), true);
if ($existing) {
foreach ($events as $date => $dateEvents) {
if (!isset($existing[$date])) {
$existing[$date] = [];
}
foreach ($dateEvents as $event) {
// Check if event with same ID exists
$found = false;
foreach ($existing[$date] as $existingEvent) {
if ($existingEvent['id'] === $event['id']) {
$found = true;
break;
}
}
if (!$found) {
$existing[$date][] = $event;
$importedCount++;
} else {
$mergedCount++;
}
}
}
file_put_contents($targetFile, json_encode($existing, JSON_PRETTY_PRINT));
}
} else {
// New file
file_put_contents($targetFile, json_encode($events, JSON_PRETTY_PRINT));
foreach ($events as $dateEvents) {
$importedCount += count($dateEvents);
}
}
}
}
// Clear cache
$cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
if (file_exists($cacheFile)) {
unlink($cacheFile);
}
$message = "Import complete! Imported $importedCount new events";
if ($mergedCount > 0) {
$message .= ", skipped $mergedCount duplicates";
}
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
header('Location: ' . $redirectUrl, true, 303);
exit;
}
private function previewCleanup() {
global $INPUT;
$cleanupType = $INPUT->str('cleanup_type', 'age');
$namespaceFilter = $INPUT->str('namespace_filter', '');
// Debug info
$debug = [];
$debug['cleanup_type'] = $cleanupType;
$debug['namespace_filter'] = $namespaceFilter;
$debug['age_value'] = $INPUT->int('age_value', 6);
$debug['age_unit'] = $INPUT->str('age_unit', 'months');
$debug['range_start'] = $INPUT->str('range_start', '');
$debug['range_end'] = $INPUT->str('range_end', '');
$debug['delete_completed'] = $INPUT->bool('delete_completed', false);
$debug['delete_past'] = $INPUT->bool('delete_past', false);
$dataDir = DOKU_INC . 'data/meta/';
$debug['data_dir'] = $dataDir;
$debug['data_dir_exists'] = is_dir($dataDir);
$eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
// Merge with scan debug info
if (isset($this->_cleanupDebug)) {
$debug = array_merge($debug, $this->_cleanupDebug);
}
// Return JSON for preview with debug info
header('Content-Type: application/json');
echo json_encode([
'count' => count($eventsToDelete),
'events' => array_slice($eventsToDelete, 0, 50), // Limit to 50 for preview
'debug' => $debug
]);
exit;
}
private function cleanupEvents() {
global $INPUT;
$cleanupType = $INPUT->str('cleanup_type', 'age');
$namespaceFilter = $INPUT->str('namespace_filter', '');
// Create backup first
$backupDir = DOKU_PLUGIN . 'calendar/backups/';
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$backupFile = $backupDir . 'before-cleanup-' . date('Y-m-d-His') . '.zip';
$this->createBackup($backupFile);
// Find events to delete
$eventsToDelete = $this->findEventsToCleanup($cleanupType, $namespaceFilter);
$deletedCount = 0;
// Group by file
$fileGroups = [];
foreach ($eventsToDelete as $evt) {
$fileGroups[$evt['file']][] = $evt;
}
// Delete from each file
foreach ($fileGroups as $file => $events) {
if (!file_exists($file)) continue;
$json = file_get_contents($file);
$data = json_decode($json, true);
if (!$data) continue;
// Remove events
foreach ($events as $evt) {
if (isset($data[$evt['date']])) {
$data[$evt['date']] = array_filter($data[$evt['date']], function($e) use ($evt) {
return $e['id'] !== $evt['id'];
});
// Remove date key if empty
if (empty($data[$evt['date']])) {
unset($data[$evt['date']]);
}
$deletedCount++;
}
}
// Save file or delete if empty
if (empty($data)) {
unlink($file);
} else {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
}
// Clear cache
$cacheFile = DOKU_PLUGIN . 'calendar/.event_stats_cache';
if (file_exists($cacheFile)) {
unlink($cacheFile);
}
$message = "Cleanup complete! Deleted $deletedCount event(s). Backup created: " . basename($backupFile);
$redirectUrl = DOKU_URL . 'doku.php?do=admin&page=calendar&tab=manage&msg=' . urlencode($message) . '&msgtype=success';
header('Location: ' . $redirectUrl, true, 303);
exit;
}
private function findEventsToCleanup($cleanupType, $namespaceFilter) {
global $INPUT;
$eventsToDelete = [];
$dataDir = DOKU_INC . 'data/meta/';
$debug = [];
$debug['scanned_dirs'] = [];
$debug['found_files'] = [];
// Calculate cutoff date for age-based cleanup
$cutoffDate = null;
if ($cleanupType === 'age') {
$ageValue = $INPUT->int('age_value', 6);
$ageUnit = $INPUT->str('age_unit', 'months');
if ($ageUnit === 'years') {
$ageValue *= 12; // Convert to months
}
$cutoffDate = date('Y-m-d', strtotime("-$ageValue months"));
$debug['cutoff_date'] = $cutoffDate;
}
// Get date range for range-based cleanup
$rangeStart = $cleanupType === 'range' ? $INPUT->str('range_start', '') : null;
$rangeEnd = $cleanupType === 'range' ? $INPUT->str('range_end', '') : null;
// Get status filters
$deleteCompleted = $cleanupType === 'status' && $INPUT->bool('delete_completed', false);
$deletePast = $cleanupType === 'status' && $INPUT->bool('delete_past', false);
// Check root calendar directory first (blank/default namespace)
$rootCalendarDir = $dataDir . 'calendar';
$debug['root_calendar_dir'] = $rootCalendarDir;
$debug['root_exists'] = is_dir($rootCalendarDir);
if (is_dir($rootCalendarDir)) {
if (!$namespaceFilter || $namespaceFilter === '' || $namespaceFilter === 'default') {
$debug['scanned_dirs'][] = $rootCalendarDir;
$files = glob($rootCalendarDir . '/*.json');
$debug['found_files'] = array_merge($debug['found_files'], $files);
$this->processCalendarFiles($rootCalendarDir, '', $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
}
}
// Scan all namespace directories
$namespaceDirs = glob($dataDir . '*', GLOB_ONLYDIR);
$debug['namespace_dirs_found'] = $namespaceDirs;
foreach ($namespaceDirs as $nsDir) {
$namespace = basename($nsDir);
// Skip the root 'calendar' dir (already processed above)
if ($namespace === 'calendar') continue;
// Check namespace filter
if ($namespaceFilter && strpos($namespace, $namespaceFilter) === false) {
continue;
}
$calendarDir = $nsDir . '/calendar';
$debug['checked_calendar_dirs'][] = $calendarDir;
if (!is_dir($calendarDir)) {
$debug['missing_calendar_dirs'][] = $calendarDir;
continue;
}
$debug['scanned_dirs'][] = $calendarDir;
$files = glob($calendarDir . '/*.json');
$debug['found_files'] = array_merge($debug['found_files'], $files);
$this->processCalendarFiles($calendarDir, $namespace, $eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast);
}
// Store debug info globally for preview
$this->_cleanupDebug = $debug;
return $eventsToDelete;
}
private function processCalendarFiles($calendarDir, $namespace, &$eventsToDelete, $cleanupType, $cutoffDate, $rangeStart, $rangeEnd, $deleteCompleted, $deletePast) {
foreach (glob($calendarDir . '/*.json') as $file) {
$json = file_get_contents($file);
$data = json_decode($json, true);
if (!$data) continue;
foreach ($data as $date => $dateEvents) {
foreach ($dateEvents as $event) {
$shouldDelete = false;
// Age-based
if ($cleanupType === 'age' && $cutoffDate && $date < $cutoffDate) {
$shouldDelete = true;
}
// Range-based
if ($cleanupType === 'range' && $rangeStart && $rangeEnd) {
if ($date >= $rangeStart && $date <= $rangeEnd) {
$shouldDelete = true;
}
}
// Status-based
if ($cleanupType === 'status') {
$isTask = isset($event['isTask']) && $event['isTask'];
$isCompleted = isset($event['completed']) && $event['completed'];
$isPast = $date < date('Y-m-d');
if ($deleteCompleted && $isTask && $isCompleted) {
$shouldDelete = true;
}
if ($deletePast && !$isTask && $isPast) {
$shouldDelete = true;
}
}
if ($shouldDelete) {
$eventsToDelete[] = [
'id' => $event['id'],
'title' => $event['title'],
'date' => $date,
'namespace' => $namespace ?: 'default',
'file' => $file
];
}
}
}
}
}
}