Calendar V3

This commit is contained in:
2026-03-11 13:15:20 +01:00
parent a4815fc672
commit 94215fdd65
13 changed files with 190 additions and 61 deletions

View File

@@ -174,6 +174,7 @@ Key settings:
- **Username**: CalDAV authentication username
- **Password**: CalDAV authentication password
- **Color**: CSS color for calendar widget indicators
- **Display**: Where to show that slot's calendar indicator (`None`, `Top Left`, `Top Right`, `Bottom Left`, `Bottom Right`)
A slot is enabled if it has a local file path or a CalDAV URL configured.
The old `calendar_ics_files` setting has been replaced by the `general` slot's file path.
@@ -300,9 +301,8 @@ Notes:
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
- Calendar output is marked as non-cacheable to keep missing/existing link styling and
current-day highlighting up to date.
- Each day cell shows colored corner indicators for calendar slots that have events on that day.
Indicator positions (clockwise): general = top-left, maintenance = top-right,
slot3 = bottom-right, slot4 = bottom-left.
- Each day cell can show colored corner triangles for slots that have events on that day.
Indicator placement is configured per slot via the `Display` setting.
Indicator colors are taken from the slot's configured color.
### 0.4) Virtual chronological day pages

View File

@@ -168,14 +168,16 @@ class action_plugin_luxtools extends ActionPlugin
$slots = CalendarSlot::loadEnabled($this);
$indicators = CalendarService::monthIndicators($slots, $year, $month);
$slotColors = [];
$slotDisplays = [];
foreach ($slots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
}
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
$html = ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays);
if ($html === '') {
http_status(500);
echo 'Calendar rendering failed';
@@ -741,7 +743,7 @@ class action_plugin_luxtools extends ActionPlugin
$remoteError = '';
if ($maintenanceSlot->hasRemoteSource()) {
try {
$caldavOk = CalDavClient::updateEventStatus(
$caldavResult = CalDavClient::updateEventStatus(
$maintenanceSlot->getCaldavUrl(),
$maintenanceSlot->getUsername(),
$maintenanceSlot->getPassword(),
@@ -750,13 +752,13 @@ class action_plugin_luxtools extends ActionPlugin
$newStatus,
$dateIso
);
if (!$caldavOk) {
if ($caldavResult !== '') {
$remoteOk = false;
$remoteError = $this->getLang('maintenance_remote_write_failed');
$remoteError = $this->getLang('maintenance_remote_write_failed') . ': ' . $caldavResult;
}
} catch (Throwable $e) {
$remoteOk = false;
$remoteError = $this->getLang('maintenance_remote_write_failed');
$remoteError = $this->getLang('maintenance_remote_write_failed') . ': ' . $e->getMessage();
}
}

View File

@@ -37,21 +37,25 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'calendar_general_username',
'calendar_general_password',
'calendar_general_color',
'calendar_general_display',
'calendar_maintenance_file',
'calendar_maintenance_caldav_url',
'calendar_maintenance_username',
'calendar_maintenance_password',
'calendar_maintenance_color',
'calendar_maintenance_display',
'calendar_slot3_file',
'calendar_slot3_caldav_url',
'calendar_slot3_username',
'calendar_slot3_password',
'calendar_slot3_color',
'calendar_slot3_display',
'calendar_slot4_file',
'calendar_slot4_caldav_url',
'calendar_slot4_username',
'calendar_slot4_password',
'calendar_slot4_color',
'calendar_slot4_display',
'pagelink_search_depth',
];
@@ -119,6 +123,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
$newConf['calendar_' . $slot . '_username'] = trim($INPUT->str('calendar_' . $slot . '_username'));
$newConf['calendar_' . $slot . '_password'] = trim($INPUT->str('calendar_' . $slot . '_password'));
$newConf['calendar_' . $slot . '_color'] = trim($INPUT->str('calendar_' . $slot . '_color'));
$newConf['calendar_' . $slot . '_display'] = trim($INPUT->str('calendar_' . $slot . '_display'));
}
$depth = (int)$INPUT->int('pagelink_search_depth');
@@ -274,6 +279,13 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'slot3' => 'Slot 3',
'slot4' => 'Slot 4',
];
$displayOptions = [
'none' => (string)$this->getLang('calendar_slot_display_none'),
'top-left' => (string)$this->getLang('calendar_slot_display_top_left'),
'top-right' => (string)$this->getLang('calendar_slot_display_top_right'),
'bottom-left' => (string)$this->getLang('calendar_slot_display_bottom_left'),
'bottom-right' => (string)$this->getLang('calendar_slot_display_bottom_right'),
];
foreach ($this->calendarSlotKeys as $slot) {
echo '<h2>' . hsc($this->getLang('calendar_slot_heading') . ': ' . $slotLabels[$slot]) . '</h2>';
@@ -298,6 +310,18 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_color')) . '</span> ';
echo '<input type="color" name="' . hsc($prefix . 'color') . '" value="' . hsc((string)$this->getConf($prefix . 'color') ?: '#999999') . '" />';
echo '</label><br />';
$currentDisplay = (string)$this->getConf($prefix . 'display');
if ($currentDisplay === '') $currentDisplay = 'none';
echo '<label class="block"><span>' . hsc($this->getLang('calendar_slot_display')) . '</span> ';
echo '<select name="' . hsc($prefix . 'display') . '" class="edit">';
foreach ($displayOptions as $value => $label) {
if ($label === '') $label = $value;
$selected = ($currentDisplay === $value) ? ' selected="selected"' : '';
echo '<option value="' . hsc($value) . '"' . $selected . '>' . hsc($label) . '</option>';
}
echo '</select>';
echo '</label><br />';
}
// CalDAV Sync button (outside the save form, separate action)

View File

@@ -38,30 +38,34 @@ $conf['open_service_url'] = 'http://127.0.0.1:8765';
$conf['image_base_path'] = '';
// Calendar slot configuration (4 slots: general, maintenance, slot3, slot4)
// Each slot has: file, caldav_url, username, password, color
// Each slot has: file, caldav_url, username, password, color, display
$conf['calendar_general_file'] = '';
$conf['calendar_general_caldav_url'] = '';
$conf['calendar_general_username'] = '';
$conf['calendar_general_password'] = '';
$conf['calendar_general_color'] = '#4a90d9';
$conf['calendar_general_display'] = 'none';
$conf['calendar_maintenance_file'] = '';
$conf['calendar_maintenance_caldav_url'] = '';
$conf['calendar_maintenance_username'] = '';
$conf['calendar_maintenance_password'] = '';
$conf['calendar_maintenance_color'] = '#e67e22';
$conf['calendar_maintenance_display'] = 'none';
$conf['calendar_slot3_file'] = '';
$conf['calendar_slot3_caldav_url'] = '';
$conf['calendar_slot3_username'] = '';
$conf['calendar_slot3_password'] = '';
$conf['calendar_slot3_color'] = '#27ae60';
$conf['calendar_slot3_display'] = 'none';
$conf['calendar_slot4_file'] = '';
$conf['calendar_slot4_caldav_url'] = '';
$conf['calendar_slot4_username'] = '';
$conf['calendar_slot4_password'] = '';
$conf['calendar_slot4_color'] = '#8e44ad';
$conf['calendar_slot4_display'] = 'none';
// Maximum depth when searching for .pagelink files under allowed roots.
$conf['pagelink_search_depth'] = 3;

View File

@@ -66,12 +66,12 @@
html += '<div class="luxtools-event-popup-field">';
if (allDay) {
html += '<strong>Date:</strong> ' + formatDate(start);
if (end) {
if (end && !isSameMoment(start, end)) {
html += ' &ndash; ' + formatDate(end);
}
} else {
html += '<strong>Time:</strong> ' + formatDateTime(start);
if (end) {
if (end && !isSameMoment(start, end)) {
html += ' &ndash; ' + formatDateTime(end);
}
}
@@ -114,14 +114,23 @@
if (!isoStr) return '';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return d.toLocaleDateString();
return pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1) + '.' + d.getFullYear();
}
function formatDateTime(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return formatDate(isoStr) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
function isSameMoment(left, right) {
if (!left || !right) return false;
return left === right;
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function escapeHtml(text) {
@@ -198,11 +207,9 @@
// Visual feedback: mark item as done or revert
if (action === 'complete') {
item.classList.add('luxtools-task-completed');
// Fade out and remove after a short delay
item.style.opacity = '0.5';
setTimeout(function () {
item.style.display = 'none';
}, 1000);
button.textContent = 'Reopen';
button.setAttribute('data-action', 'reopen');
button.disabled = false;
} else {
item.classList.remove('luxtools-task-completed');
item.style.opacity = '1';

View File

@@ -86,9 +86,10 @@
var formatter;
try {
formatter = new Intl.DateTimeFormat(undefined, {
formatter = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
hour12: false
});
} catch (e) {
formatter = null;

View File

@@ -70,6 +70,12 @@ $lang["calendar_slot_caldav_url"] = "CalDAV-URL";
$lang["calendar_slot_username"] = "Benutzername";
$lang["calendar_slot_password"] = "Passwort";
$lang["calendar_slot_color"] = "Anzeigefarbe";
$lang["calendar_slot_display"] = "Anzeige";
$lang["calendar_slot_display_none"] = "Keine";
$lang["calendar_slot_display_top_left"] = "Oben links";
$lang["calendar_slot_display_top_right"] = "Oben rechts";
$lang["calendar_slot_display_bottom_left"] = "Unten links";
$lang["calendar_slot_display_bottom_right"] = "Unten rechts";
$lang["pagelink_search_depth"] =
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";

View File

@@ -70,6 +70,12 @@ $lang["calendar_slot_caldav_url"] = "CalDAV URL";
$lang["calendar_slot_username"] = "Username";
$lang["calendar_slot_password"] = "Password";
$lang["calendar_slot_color"] = "Display color";
$lang["calendar_slot_display"] = "Display";
$lang["calendar_slot_display_none"] = "None";
$lang["calendar_slot_display_top_left"] = "Top Left";
$lang["calendar_slot_display_top_right"] = "Top Right";
$lang["calendar_slot_display_bottom_left"] = "Bottom Left";
$lang["calendar_slot_display_bottom_right"] = "Bottom Right";
$lang["pagelink_search_depth"] =
"Maximum directory depth for .pagelink search (0 = only root).";

View File

@@ -20,6 +20,9 @@ class CalDavClient
/** @var int HTTP timeout in seconds */
protected const TIMEOUT = 30;
/** @var string Last request error message for diagnostics */
protected static string $lastRequestError = '';
/**
* Update the STATUS of a specific event or task on the remote CalDAV server.
*
@@ -33,7 +36,7 @@ class CalDavClient
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
* @param string $dateIso Occurrence date YYYY-MM-DD
* @return bool True if the remote update succeeded
* @return string Empty string on success, error message on failure
*/
public static function updateEventStatus(
string $caldavUrl,
@@ -43,13 +46,17 @@ class CalDavClient
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
if ($caldavUrl === '' || $uid === '') return false;
): string {
if ($caldavUrl === '' || $uid === '') return 'Missing CalDAV URL or UID';
try {
// Find the calendar object href for this UID via REPORT
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
if ($objectInfo === null) return false;
if ($objectInfo === null) {
$msg = "CalDAV: Could not find object with UID '$uid' on server";
dbglog($msg);
return $msg;
}
$objectHref = $objectInfo['href'];
$etag = $objectInfo['etag'];
@@ -57,19 +64,35 @@ class CalDavClient
// Parse and update the status
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
if (!($calendar instanceof VCalendar)) {
$msg = "CalDAV: Failed to parse calendar data for UID '$uid'";
dbglog($msg);
return $msg;
}
$updated = IcsWriter::applyStatusUpdateToCalendar(
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
);
if (!$updated) return false;
if (!$updated) {
$msg = "CalDAV: applyStatusUpdateToCalendar failed for UID '$uid'";
dbglog($msg);
return $msg;
}
$newData = $calendar->serialize();
// PUT the updated object back with If-Match for conflict detection
return self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
$putError = self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
if ($putError !== '') {
dbglog($putError);
return $putError;
}
return '';
} catch (Throwable $e) {
return false;
$msg = 'CalDAV: Exception during updateEventStatus: ' . $e->getMessage();
dbglog($msg);
return $msg;
}
}
@@ -243,7 +266,7 @@ class CalDavClient
* @param string $password
* @param string $data ICS data to write
* @param string $etag ETag for If-Match header (empty to skip)
* @return bool
* @return string Empty string on success, error message on failure
*/
protected static function putCalendarObject(
string $href,
@@ -251,7 +274,7 @@ class CalDavClient
string $password,
string $data,
string $etag
): bool {
): string {
$headers = [
'Content-Type: text/calendar; charset=utf-8',
];
@@ -260,9 +283,10 @@ class CalDavClient
}
$response = self::request('PUT', $href, $username, $password, $data, $headers);
// PUT returns null body but we check by HTTP status via the request method
// A successful PUT returns 2xx
return $response !== null;
if ($response === null) {
return self::$lastRequestError ?: 'CalDAV PUT failed (unknown error)';
}
return '';
}
/**
@@ -293,8 +317,6 @@ class CalDavClient
$etags = $resp->xpath('.//d:getetag');
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
// Strip surrounding quotes from etag if present
$etag = trim($etag, '"');
$caldata = $resp->xpath('.//cal:calendar-data');
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
@@ -392,10 +414,18 @@ class CalDavClient
string $body = '',
array $headers = []
): ?string {
if (!function_exists('curl_init')) return null;
self::$lastRequestError = '';
if (!function_exists('curl_init')) {
self::$lastRequestError = 'CalDAV: curl extension not available';
return null;
}
$ch = curl_init();
if ($ch === false) return null;
if ($ch === false) {
self::$lastRequestError = 'CalDAV: curl_init() failed';
return null;
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
@@ -423,9 +453,13 @@ class CalDavClient
// Capture HTTP status code
$responseBody = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if (!is_string($responseBody)) return null;
if (!is_string($responseBody)) {
self::$lastRequestError = "CalDAV $method failed: curl error: $curlError";
return null;
}
// Accept 2xx and 207 (multistatus) responses
if ($httpCode >= 200 && $httpCode < 300) {
@@ -435,6 +469,7 @@ class CalDavClient
return $responseBody;
}
self::$lastRequestError = "CalDAV $method failed: HTTP $httpCode";
return null;
}
}

View File

@@ -6,13 +6,17 @@ namespace dokuwiki\plugin\luxtools;
* Represents one calendar slot configuration.
*
* Each slot has a stable key, a human-readable label, local/remote source
* configuration, a display color, and a derived enabled state.
* configuration, a display color, an optional widget indicator position,
* and a derived enabled state.
*/
class CalendarSlot
{
/** @var string[] Ordered list of all supported slot keys */
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4'];
/** @var string[] Allowed widget indicator display positions */
public const INDICATOR_DISPLAYS = ['none', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
/** @var array<string,string> Human-readable labels for slot keys */
public const SLOT_LABELS = [
'general' => 'General',
@@ -42,6 +46,9 @@ class CalendarSlot
/** @var string CSS color for widget indicators */
protected $color;
/** @var string Widget indicator display position */
protected $display;
/**
* @param string $key
* @param string $file
@@ -49,6 +56,7 @@ class CalendarSlot
* @param string $username
* @param string $password
* @param string $color
* @param string $display
*/
public function __construct(
string $key,
@@ -56,7 +64,8 @@ class CalendarSlot
string $caldavUrl = '',
string $username = '',
string $password = '',
string $color = ''
string $color = '',
string $display = 'none'
) {
$this->key = $key;
$this->label = self::SLOT_LABELS[$key] ?? $key;
@@ -65,6 +74,7 @@ class CalendarSlot
$this->username = trim($username);
$this->password = trim($password);
$this->color = trim($color);
$this->display = self::normalizeIndicatorDisplay($display);
}
public function getKey(): string
@@ -102,6 +112,16 @@ class CalendarSlot
return $this->color;
}
public function getDisplay(): string
{
return $this->display;
}
public function shouldDisplayIndicator(): bool
{
return $this->display !== 'none';
}
/**
* A slot is enabled if it has a local file path or a CalDAV URL.
*
@@ -149,7 +169,8 @@ class CalendarSlot
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
(string)$plugin->getConf('calendar_' . $key . '_username'),
(string)$plugin->getConf('calendar_' . $key . '_password'),
(string)$plugin->getConf('calendar_' . $key . '_color')
(string)$plugin->getConf('calendar_' . $key . '_color'),
(string)$plugin->getConf('calendar_' . $key . '_display')
);
}
return $slots;
@@ -168,4 +189,14 @@ class CalendarSlot
return $slot->isEnabled();
});
}
protected static function normalizeIndicatorDisplay(string $display): string
{
$display = strtolower(trim($display));
$display = str_replace(['_', ' '], '-', $display);
if (!in_array($display, self::INDICATOR_DISPLAYS, true)) {
return 'none';
}
return $display;
}
}

View File

@@ -15,6 +15,7 @@ class ChronologicalCalendarWidget
* @param string $baseNs
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
* @param array<string,string> $slotColors slotKey => CSS color
* @param array<string,string> $slotDisplays slotKey => configured indicator position
* @return string
*/
public static function render(
@@ -22,7 +23,8 @@ class ChronologicalCalendarWidget
int $month,
string $baseNs = 'chronological',
array $indicators = [],
array $slotColors = []
array $slotColors = [],
array $slotDisplays = []
): string {
if (!self::isValidMonth($year, $month)) return '';
@@ -120,13 +122,19 @@ class ChronologicalCalendarWidget
// Render slot indicators if any
$dayIndicators = $indicators[$date] ?? [];
if ($dayIndicators !== []) {
$html .= '<div class="luxtools-calendar-indicators">';
$indicatorHtml = '';
foreach ($dayIndicators as $slotKey) {
$display = $slotDisplays[$slotKey] ?? 'none';
if ($display === 'none') continue;
$color = $slotColors[$slotKey] ?? '';
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
$html .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($slotKey) . '"' . $style . '></span>';
$indicatorHtml .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($display) . '"' . $style . '></span>';
}
if ($indicatorHtml !== '') {
$html .= '<div class="luxtools-calendar-indicators">' . $indicatorHtml . '</div>';
}
$html .= '</div>';
}
if ($dayId !== null && function_exists('html_wikilink')) {

View File

@@ -668,29 +668,32 @@ div.luxtools-calendar td.luxtools-calendar-day {
.luxtools-calendar-indicator {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
width: 10px;
height: 10px;
}
.luxtools-indicator-general {
top: 2px;
left: 2px;
.luxtools-indicator-top-left {
top: 0;
left: 0;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
.luxtools-indicator-maintenance {
top: 2px;
right: 2px;
.luxtools-indicator-top-right {
top: 0;
right: 0;
clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.luxtools-indicator-slot3 {
bottom: 2px;
right: 2px;
.luxtools-indicator-bottom-right {
bottom: 0;
right: 0;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.luxtools-indicator-slot4 {
bottom: 2px;
left: 2px;
.luxtools-indicator-bottom-left {
bottom: 0;
left: 0;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}

View File

@@ -91,14 +91,16 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$slots = CalendarSlot::loadEnabled($this);
$indicators = CalendarService::monthIndicators($slots, $year, $month);
$slotColors = [];
$slotDisplays = [];
foreach ($slots as $slot) {
$color = $slot->getColor();
if ($color !== '') {
$slotColors[$slot->getKey()] = $color;
}
$slotDisplays[$slot->getKey()] = $slot->getDisplay();
}
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors);
$renderer->doc .= ChronologicalCalendarWidget::render($year, $month, $baseNs, $indicators, $slotColors, $slotDisplays);
return true;
}