Compare commits

..

16 Commits

Author SHA1 Message Date
e98f9ad2d9 Update deploy scripts 2026-04-06 08:49:33 +02:00
c1ffbc3f3a Improve date handling for the calendar widget 2026-04-06 08:49:14 +02:00
5c74c2e667 Fix timezone parsing for calendars 2026-04-03 15:40:06 +02:00
1cfd935794 Add zed settings 2026-04-03 15:27:34 +02:00
8a36333883 Add Validation for time imputs 2026-04-03 15:27:26 +02:00
e32d69dcc3 Fix date parsing in event-popup 2026-04-03 15:14:42 +02:00
5c747aaa78 Remove vscode config 2026-04-03 15:12:12 +02:00
abe805926a Fix time display 2026-04-03 15:11:52 +02:00
d33c7a748b Remove maintenance feature 2026-04-03 14:53:05 +02:00
946c269d42 Defer Script Tags 2026-03-25 13:37:30 +01:00
a3f021e5e1 Unify dialog infrastructure 2026-03-20 07:56:41 +01:00
96cc82db9e Fix time dispaly in day popup 2026-03-18 14:33:03 +01:00
975e195ae3 improve calendar features 2026-03-18 14:17:38 +01:00
14d4a2895a Refinements for movie display 2026-03-17 12:51:18 +01:00
59a430938b Fix API key passing 2026-03-17 12:41:18 +01:00
211418c6c4 Movie import v1 2026-03-17 12:36:12 +01:00
33 changed files with 4153 additions and 1460 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
_agent-data/ _agent-data/
.claude/

26
.vscode/settings.json vendored
View File

@@ -1,26 +0,0 @@
{
// Developer quality-of-life:
// If you add a DokuWiki checkout as ./_dokuwiki (see README), Intelephense will
// index it and resolve DokuWiki base classes (ActionPlugin, SyntaxPlugin, etc.).
"intelephense.environment.includePaths": [
"./_dokuwiki",
"./_dokuwiki/inc",
"./_dokuwiki/lib",
"./_dokuwiki/vendor"
],
// DokuWiki replaces @ini_* placeholders server-side.
// VS Code's CSS validator doesn't understand those tokens, but LESS does.
"files.associations": {
"style.css": "less",
"temp-input-colors.css": "less"
},
// Keep the file explorer tidy when the optional DokuWiki checkout exists.
"files.exclude": {
"**/_dokuwiki/.git": true,
"**/_dokuwiki/data": true,
"**/_dokuwiki/conf": true,
"**/_dokuwiki/cache": true
}
}

5
.zed/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"file_types": {
"LESS": ["style.css"],
},
}

183
README.md
View File

@@ -189,6 +189,10 @@ Key settings:
Maximum directory depth for `.pagelink` discovery under each configured root. Maximum directory depth for `.pagelink` discovery under each configured root.
`0` means only the root directory itself is checked. `0` means only the root directory itself is checked.
- **omdb_apikey**
OMDb API key used for the movie import toolbar button. The key is sent to the
browser for client-side API requests and will be visible in developer tools.
### Template style settings ### Template style settings
The `{{open>...}}` links and directory “open” links use a dedicated color The `{{open>...}}` links and directory “open” links use a dedicated color
@@ -254,7 +258,43 @@ Supported input examples include:
- `2026-01-30 13:45` - `2026-01-30 13:45`
- `2026-01-30T13:45:00` - `2026-01-30T13:45:00`
### 0.2) Page Link: link a page to a folder ### 0.2) Editor toolbar: Movie Import
The plugin adds a toolbar button for importing movie metadata from the [OMDb API](https://www.omdbapi.com/).
**Setup:**
1. Obtain an OMDb API key from https://www.omdbapi.com/apikey.aspx
2. Enter the key in **Admin → luxtools** under "Movie Import (OMDb)".
**Usage:**
1. Open a page for editing.
2. Click the movie icon in the toolbar.
3. A prompt appears pre-filled with the first heading from the page (e.g. `Project Hail Mary (2026)`).
4. Edit the title if needed and confirm.
5. The plugin queries OMDb and inserts a movie metadata block into the editor.
**Inserted markup example:**
```
<!-- BEGIN MOVIE -->
{{image>https://...poster.jpg}}
^ Title | Project Hail Mary |
^ Year | 2026 |
^ Genre | Adventure, Drama, Sci-Fi |
^ Director | Phil Lord, Christopher Miller |
^ Actors | Ryan Gosling |
^ Plot | A lone astronaut must save Earth... |
<!-- END MOVIE -->
```
**Re-import behavior:** Running the import again replaces the existing movie block
(delimited by `<!-- BEGIN MOVIE -->` / `<!-- END MOVIE -->` markers) instead of
appending a duplicate.
**Security note:** The OMDb API key is passed to the browser and used for
client-side requests. It is visible in browser developer tools and network
traffic. This is an intentional tradeoff for this single-user LAN deployment.
### 0.3) Page Link: link a page to a folder
Page linking uses a page-scoped UUID stored in page metadata. This UUID is used Page linking uses a page-scoped UUID stored in page metadata. This UUID is used
to link the page to a folder that contains a `.pagelink` file with the same UUID. to link the page to a folder that contains a `.pagelink` file with the same UUID.
@@ -282,7 +322,7 @@ for example:
{{directory>blobs/&recursive=1}} {{directory>blobs/&recursive=1}}
``` ```
### 0.3) Calendar widget ### 0.4) Calendar widget
Render a basic monthly calendar that links each day to canonical chronological pages: Render a basic monthly calendar that links each day to canonical chronological pages:
@@ -311,7 +351,7 @@ Notes:
- Indicator placement in small mode is configured per slot via the `Display` setting. - Indicator placement in small mode is configured per slot via the `Display` setting.
- Slot colors are reused for both indicators and inline event accents. - Slot colors are reused for both indicators and inline event accents.
### 0.4) Virtual chronological day pages ### 0.5) Virtual chronological day pages
When a canonical day page (for example `chronological:2026:02:13`) does not yet When a canonical day page (for example `chronological:2026:02:13`) does not yet
exist, luxtools renders a virtual page in normal show mode instead of the exist, luxtools renders a virtual page in normal show mode instead of the
@@ -326,7 +366,7 @@ The virtual page includes:
The page is only created once you edit and save actual content. The page is only created once you edit and save actual content.
### 0.5) Cache invalidation ### 0.6) Cache invalidation
luxtools provides an admin-only **Invalidate Cache** action in the page tools menu. luxtools provides an admin-only **Invalidate Cache** action in the page tools menu.
@@ -338,7 +378,7 @@ luxtools provides an admin-only **Invalidate Cache** action in the page tools me
permission errors). permission errors).
- Also useful when actively adding external photos to the current day page. - Also useful when actively adding external photos to the current day page.
### 0.6) Multi-calendar slot system ### 0.7) Multi-calendar slot system
The plugin supports 4 calendar slots, each with independent configuration for The plugin supports 4 calendar slots, each with independent configuration for
a local `.ics` file, CalDAV URL, authentication, and display color. a local `.ics` file, CalDAV URL, authentication, and display color.
@@ -352,7 +392,7 @@ a local `.ics` file, CalDAV URL, authentication, and display color.
Calendar data is always read from local `.ics` files for rendering. If a remote Calendar data is always read from local `.ics` files for rendering. If a remote
CalDAV source is configured, use the sync feature to populate the local file. CalDAV source is configured, use the sync feature to populate the local file.
### 0.7) Maintenance task completion ### 0.8) Maintenance task completion
Maintenance tasks shown on day pages include a "Complete" button. Clicking it: Maintenance tasks shown on day pages include a "Complete" button. Clicking it:
@@ -368,7 +408,7 @@ Write-back rules:
- Recurring events: Completion writes an occurrence override/exception to preserve - Recurring events: Completion writes an occurrence override/exception to preserve
per-occurrence state rather than modifying the master event. per-occurrence state rather than modifying the master event.
### 0.8) Event popup ### 0.9) Event popup
Clicking any event on a day page opens a popup overlay showing: Clicking any event on a day page opens a popup overlay showing:
- Title - Title
@@ -379,7 +419,7 @@ Clicking any event on a day page opens a popup overlay showing:
Close the popup by clicking outside it or pressing Escape. Close the popup by clicking outside it or pressing Escape.
### 0.9) Maintenance task list syntax ### 0.10) Maintenance task list syntax
Embed a list of open maintenance tasks anywhere on a wiki page: Embed a list of open maintenance tasks anywhere on a wiki page:
@@ -401,14 +441,123 @@ window are hidden. The default is `30`.
Each task shows its date, optional time, summary, and a "Complete" button. Each task shows its date, optional time, summary, and a "Complete" button.
### 0.10) CalDAV sync ### 0.11) CalDAV sync
If a slot has a CalDAV URL configured, the admin panel provides a sync button. If a slot has a CalDAV URL configured, the admin panel provides a sync button.
Triggering sync downloads all calendar objects from the remote CalDAV collection Triggering sync downloads all calendar objects from the remote CalDAV collection
and merges them into the slot's local `.ics` file. and merges them into the slot's local `.ics` file.
Sync is admin-only and does not run automatically. For scheduled sync, set up Sync can also be triggered from any wiki page using the inline syntax:
a cron job that triggers the sync via the DokuWiki AJAX endpoint.
```
{{calendar_sync>}}
```
The sync button is only visible to admins. Non-admin users see nothing.
Sync is admin-only and does not run automatically. For scheduled sync, see the
automatic sync section below.
### 0.12) Event popup improvements
Clicking an event on a day page or in the calendar widget opens the event detail popup.
When the popup is opened from a specific day context (e.g. clicking an event in a calendar
day cell), the date is hidden and only the time is shown (since the day is already known).
Clicking empty space inside a calendar day cell opens a day popup listing all events for
that day. If there are no events, a "No events" message is shown.
### 0.13) Event creation
Authenticated users can create new calendar events from the day popup.
1. Click empty space in a calendar day cell to open the day popup.
2. Click "Create Event".
3. Fill in the form: summary (required), date, all-day toggle, start/end time,
location, description, and target calendar slot.
4. Click "Save".
The event is written to the local `.ics` file immediately. If the slot has a
CalDAV URL configured, the event is also uploaded to the remote server.
### 0.14) Event editing
Authenticated users can edit existing calendar events from the event popup.
1. Click an event to open the detail popup.
2. Click "Edit".
3. Modify the fields and click "Save".
For recurring events, editing creates or updates an occurrence override rather
than modifying the master event.
Changes are written to the local `.ics` file first, then pushed to CalDAV if
configured.
### 0.15) Event deletion
Authenticated users can delete events from the event popup.
1. Click an event to open the detail popup.
2. Click "Delete".
3. Confirm the deletion.
For recurring events, you are asked whether to delete:
- **This occurrence**: Adds an EXDATE to the master event.
- **This and future occurrences**: Sets an UNTIL date on the RRULE.
- **All occurrences**: Removes all components with this UID.
Deletion is applied to the local `.ics` file first, then to CalDAV if configured.
### 0.16) Automatic sync proposal
Automatic calendar sync is not currently implemented. The recommended approach
for a long-lived personal DokuWiki plugin:
**Option A: Cron calling the AJAX endpoint (simplest)**
```cron
*/15 * * * * curl -s -X POST 'https://wiki.example.com/lib/exe/ajax.php' \
-d 'call=luxtools_calendar_sync&sectok=ADMIN_TOKEN' \
--cookie 'DokuWikiCookie=SESSION_ID'
```
Pros: Reuses the existing sync endpoint with no code changes.
Cons: Requires a valid admin session cookie and security token, which expire and
must be refreshed. Fragile for long-term unattended use.
**Option B: CLI script (recommended)**
Create `bin/calendar-sync.php` that boots DokuWiki from the command line,
loads the plugin, and calls `CalendarSyncService::syncAll()` directly.
```cron
*/15 * * * * php /path/to/dokuwiki/lib/plugins/luxtools/bin/calendar-sync.php
```
Pros: No session management, no authentication needed, runs as the web server user.
Cons: Requires a small CLI entry point script.
**Recommendation**: Option B is more robust and maintainable. A CLI script can
be as simple as:
```php
<?php
// Boot DokuWiki
define('DOKU_INC', '/path/to/dokuwiki/');
require_once(DOKU_INC . 'inc/init.php');
require_once(__DIR__ . '/../autoload.php');
use dokuwiki\plugin\luxtools\CalendarSlot;
use dokuwiki\plugin\luxtools\CalendarSyncService;
$plugin = plugin_load('action', 'luxtools');
if (!$plugin) { fwrite(STDERR, "Plugin not loaded\n"); exit(1); }
$slots = CalendarSlot::loadEnabled($plugin);
$result = CalendarSyncService::syncAll($slots);
echo $result['ok'] ? "Sync completed.\n" : "Sync completed with errors.\n";
```
### Known limitations ### Known limitations
@@ -420,10 +569,14 @@ a cron job that triggers the sync via the DokuWiki AJAX endpoint.
overwrite it on next sync. overwrite it on next sync.
- **Sync direction**: Sync is currently one-directional (remote → local). Local - **Sync direction**: Sync is currently one-directional (remote → local). Local
changes made via task completion are written back to the remote individually, changes (event creation, editing, deletion, task completion) are written back
but a full remote-to-local sync may overwrite local changes if the remote to the remote immediately when CalDAV is configured. However, a full
still has stale data. The completion write-back updates the remote immediately remote-to-local sync may overwrite local changes if the remote still has stale
to mitigate this. data. The immediate write-back mitigates this in most cases.
- **Event creation/editing permissions**: Event CRUD operations require an
authenticated DokuWiki user. Admin access is not required for event management,
only for calendar sync.
### 1) List files by glob pattern ### 1) List files by glob pattern

1418
action.php

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ if (!defined('DOKU_INC')) die();
class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
{ {
/** @var string[] Calendar slot keys */ /** @var string[] Calendar slot keys */
protected $calendarSlotKeys = ['general', 'maintenance', 'slot3', 'slot4']; protected $calendarSlotKeys = ['general', 'slot2', 'slot3', 'slot4'];
/** @var string[] */ /** @var string[] */
protected $configKeys = [ protected $configKeys = [
@@ -38,12 +38,12 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'calendar_general_password', 'calendar_general_password',
'calendar_general_color', 'calendar_general_color',
'calendar_general_display', 'calendar_general_display',
'calendar_maintenance_file', 'calendar_slot2_file',
'calendar_maintenance_caldav_url', 'calendar_slot2_caldav_url',
'calendar_maintenance_username', 'calendar_slot2_username',
'calendar_maintenance_password', 'calendar_slot2_password',
'calendar_maintenance_color', 'calendar_slot2_color',
'calendar_maintenance_display', 'calendar_slot2_display',
'calendar_slot3_file', 'calendar_slot3_file',
'calendar_slot3_caldav_url', 'calendar_slot3_caldav_url',
'calendar_slot3_username', 'calendar_slot3_username',
@@ -57,6 +57,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'calendar_slot4_color', 'calendar_slot4_color',
'calendar_slot4_display', 'calendar_slot4_display',
'pagelink_search_depth', 'pagelink_search_depth',
'omdb_apikey',
]; ];
public function getMenuText($language) public function getMenuText($language)
@@ -130,6 +131,8 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
if ($depth < 0) $depth = 0; if ($depth < 0) $depth = 0;
$newConf['pagelink_search_depth'] = $depth; $newConf['pagelink_search_depth'] = $depth;
$newConf['omdb_apikey'] = trim($INPUT->str('omdb_apikey'));
if ($this->savePluginLocalConf($newConf)) { if ($this->savePluginLocalConf($newConf)) {
msg($this->getLang('saved'), 1); msg($this->getLang('saved'), 1);
} else { } else {
@@ -275,7 +278,7 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
// Calendar slot settings // Calendar slot settings
$slotLabels = [ $slotLabels = [
'general' => 'General', 'general' => 'General',
'maintenance' => 'Maintenance', 'slot2' => 'Slot 2',
'slot3' => 'Slot 3', 'slot3' => 'Slot 3',
'slot4' => 'Slot 4', 'slot4' => 'Slot 4',
]; ];
@@ -360,6 +363,13 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />'; echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
echo '</label><br />'; echo '</label><br />';
// OMDb API key
echo '<h2>' . hsc($this->getLang('omdb_heading')) . '</h2>';
echo '<label class="block"><span>' . hsc($this->getLang('omdb_apikey')) . '</span> ';
echo '<input type="text" class="edit" name="omdb_apikey" value="' . hsc((string)$this->getConf('omdb_apikey')) . '" />';
echo '</label><br />';
echo '<p><em>' . hsc($this->getLang('omdb_apikey_note')) . '</em></p>';
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>'; echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
echo '</fieldset>'; echo '</fieldset>';

View File

@@ -37,7 +37,7 @@ $conf['open_service_url'] = 'http://127.0.0.1:8765';
// Base filesystem path for chronological photo integration. // Base filesystem path for chronological photo integration.
$conf['image_base_path'] = ''; $conf['image_base_path'] = '';
// Calendar slot configuration (4 slots: general, maintenance, slot3, slot4) // Calendar slot configuration (4 slots: general, slot2, slot3, slot4)
// Each slot has: file, caldav_url, username, password, color, display // Each slot has: file, caldav_url, username, password, color, display
$conf['calendar_general_file'] = ''; $conf['calendar_general_file'] = '';
$conf['calendar_general_caldav_url'] = ''; $conf['calendar_general_caldav_url'] = '';
@@ -46,12 +46,12 @@ $conf['calendar_general_password'] = '';
$conf['calendar_general_color'] = '#4a90d9'; $conf['calendar_general_color'] = '#4a90d9';
$conf['calendar_general_display'] = 'none'; $conf['calendar_general_display'] = 'none';
$conf['calendar_maintenance_file'] = ''; $conf['calendar_slot2_file'] = '';
$conf['calendar_maintenance_caldav_url'] = ''; $conf['calendar_slot2_caldav_url'] = '';
$conf['calendar_maintenance_username'] = ''; $conf['calendar_slot2_username'] = '';
$conf['calendar_maintenance_password'] = ''; $conf['calendar_slot2_password'] = '';
$conf['calendar_maintenance_color'] = '#e67e22'; $conf['calendar_slot2_color'] = '#e67e22';
$conf['calendar_maintenance_display'] = 'none'; $conf['calendar_slot2_display'] = 'none';
$conf['calendar_slot3_file'] = ''; $conf['calendar_slot3_file'] = '';
$conf['calendar_slot3_caldav_url'] = ''; $conf['calendar_slot3_caldav_url'] = '';
@@ -70,6 +70,9 @@ $conf['calendar_slot4_display'] = 'none';
// Maximum depth when searching for .pagelink files under allowed roots. // Maximum depth when searching for .pagelink files under allowed roots.
$conf['pagelink_search_depth'] = 3; $conf['pagelink_search_depth'] = 3;
// OMDb API key for movie metadata import (used client-side).
$conf['omdb_apikey'] = '';
// Image syntax defaults // Image syntax defaults
$conf['default_image_width'] = 250; $conf['default_image_width'] = 250;
$conf['default_image_align'] = 'right'; // left|right|center $conf['default_image_align'] = 'right'; // left|right|center

View File

@@ -11,7 +11,8 @@ $TARGET = "S:\7-Infrastructure\lib\plugins\luxtools"
$DRY_RUN = $false $DRY_RUN = $false
$DELETE = $true $DELETE = $true
function Resolve-PathUsingExistingCase { function Resolve-PathUsingExistingCase
{
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Path [string]$Path
@@ -20,7 +21,8 @@ function Resolve-PathUsingExistingCase {
$fullPath = [System.IO.Path]::GetFullPath($Path) $fullPath = [System.IO.Path]::GetFullPath($Path)
$root = [System.IO.Path]::GetPathRoot($fullPath).TrimEnd('\\') $root = [System.IO.Path]::GetPathRoot($fullPath).TrimEnd('\\')
if ($fullPath.TrimEnd('\\') -ieq $root) { if ($fullPath.TrimEnd('\\') -ieq $root)
{
return $root return $root
} }
@@ -28,12 +30,14 @@ function Resolve-PathUsingExistingCase {
$leaf = Split-Path -Path $fullPath -Leaf $leaf = Split-Path -Path $fullPath -Leaf
$resolvedParent = Resolve-PathUsingExistingCase -Path $parent $resolvedParent = Resolve-PathUsingExistingCase -Path $parent
if (Test-Path -LiteralPath $resolvedParent) { if (Test-Path -LiteralPath $resolvedParent)
{
$match = Get-ChildItem -LiteralPath $resolvedParent -Force -ErrorAction SilentlyContinue | $match = Get-ChildItem -LiteralPath $resolvedParent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq $leaf } | Where-Object { $_.Name -ieq $leaf } |
Select-Object -First 1 Select-Object -First 1
if ($null -ne $match) { if ($null -ne $match)
{
return (Join-Path -Path $resolvedParent -ChildPath $match.Name) return (Join-Path -Path $resolvedParent -ChildPath $match.Name)
} }
} }
@@ -41,18 +45,22 @@ function Resolve-PathUsingExistingCase {
return (Join-Path -Path $resolvedParent -ChildPath $leaf) return (Join-Path -Path $resolvedParent -ChildPath $leaf)
} }
foreach ($arg in $args) { foreach ($arg in $args)
if ($arg -eq "--dry-run" -or $arg -eq "-n") { {
if ($arg -eq "--dry-run" -or $arg -eq "-n")
{
$DRY_RUN = $true $DRY_RUN = $true
continue continue
} }
if ($arg -eq "--no-delete") { if ($arg -eq "--no-delete")
{
$DELETE = $false $DELETE = $false
continue continue
} }
if ($arg -eq "-h" -or $arg -eq "--help") { if ($arg -eq "-h" -or $arg -eq "--help")
{
Get-Content $PSCommandPath Get-Content $PSCommandPath
exit 0 exit 0
} }
@@ -65,7 +73,8 @@ $TARGET = Resolve-PathUsingExistingCase -Path $TARGET
$SRC_DIR = $PSScriptRoot $SRC_DIR = $PSScriptRoot
# Safety checks: make sure source looks like luxtools plugin # Safety checks: make sure source looks like luxtools plugin
if (-not (Test-Path "$SRC_DIR/plugin.info.txt")) { if (-not (Test-Path "$SRC_DIR/plugin.info.txt"))
{
Write-Error "Error: '$SRC_DIR' doesn't look like luxtools (missing plugin.info.txt)." Write-Error "Error: '$SRC_DIR' doesn't look like luxtools (missing plugin.info.txt)."
exit 1 exit 1
} }
@@ -75,11 +84,14 @@ New-Item -ItemType Directory -Force -Path "$TARGET" | Out-Null
# Safety check: refuse to deploy to an obviously wrong directory. # Safety check: refuse to deploy to an obviously wrong directory.
# Allow empty dir (fresh install) OR existing luxtools plugin dir. # Allow empty dir (fresh install) OR existing luxtools plugin dir.
if (Test-Path "$TARGET/plugin.info.txt") { if (Test-Path "$TARGET/plugin.info.txt")
{
$content = Get-Content "$TARGET/plugin.info.txt" -ErrorAction SilentlyContinue $content = Get-Content "$TARGET/plugin.info.txt" -ErrorAction SilentlyContinue
if ($content -match "^base\s+luxtools" -or $content -match "^base\s+luxtools\s+") { if ($content -match "^base\s+luxtools" -or $content -match "^base\s+luxtools\s+")
{
# It's a luxtools plugin, allow it # It's a luxtools plugin, allow it
} else { } else
{
Write-Error "Error: target '$TARGET' has a plugin.info.txt, but it doesn't look like luxtools." Write-Error "Error: target '$TARGET' has a plugin.info.txt, but it doesn't look like luxtools."
Write-Error "Refusing to deploy." Write-Error "Refusing to deploy."
exit 1 exit 1
@@ -92,6 +104,8 @@ $EXCLUDE_DIRS = @(
"_agent-data", "_agent-data",
".github", ".github",
".vscode", ".vscode",
".zed",
".claude",
"_test" "_test"
) )
@@ -121,21 +135,25 @@ $ROBOCOPY_ARGS = @(
"/Z" # restartable mode "/Z" # restartable mode
) )
if ($EXCLUDE_DIRS.Count -gt 0) { if ($EXCLUDE_DIRS.Count -gt 0)
{
$ROBOCOPY_ARGS += "/XD" $ROBOCOPY_ARGS += "/XD"
$ROBOCOPY_ARGS += $EXCLUDE_DIRS $ROBOCOPY_ARGS += $EXCLUDE_DIRS
} }
if ($EXCLUDE_FILES.Count -gt 0) { if ($EXCLUDE_FILES.Count -gt 0)
{
$ROBOCOPY_ARGS += "/XF" $ROBOCOPY_ARGS += "/XF"
$ROBOCOPY_ARGS += $EXCLUDE_FILES $ROBOCOPY_ARGS += $EXCLUDE_FILES
} }
if ($DRY_RUN) { if ($DRY_RUN)
{
$ROBOCOPY_ARGS += "/L" # list mode $ROBOCOPY_ARGS += "/L" # list mode
} }
if ($DELETE) { if ($DELETE)
{
$ROBOCOPY_ARGS += "/MIR" # mirror $ROBOCOPY_ARGS += "/MIR" # mirror
} }

View File

@@ -72,6 +72,8 @@ RSYNC_ARGS=(
--exclude=_agent-data/ --exclude=_agent-data/
--exclude=.github/ --exclude=.github/
--exclude=.vscode/ --exclude=.vscode/
--exclude=.zed/
--exclude=.claude/
--exclude=_test/ --exclude=_test/
--exclude=deleted.files --exclude=deleted.files
--exclude=*.swp --exclude=*.swp

62
dialog.css Normal file
View File

@@ -0,0 +1,62 @@
/* ============================================================
* Dialog Infrastructure (shared overlay & popup)
* ============================================================ */
.luxtools-dialog-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 10000;
justify-content: center;
align-items: center;
}
.luxtools-dialog {
background: @ini_background;
border: 1px solid @ini_border;
border-radius: 0.4em;
padding: 1.5em;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.luxtools-dialog-close {
position: absolute;
top: 0.5em;
right: 0.75em;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: @ini_text;
line-height: 1;
}
.luxtools-dialog-close:hover {
opacity: 0.7;
}
.luxtools-dialog-title {
margin: 0 0 0.75em 0;
padding-right: 1.5em;
}
.luxtools-dialog-field {
margin: 0.5em 0;
}
.luxtools-dialog-actions {
margin-top: 1em;
padding-top: 0.75em;
border-top: 1px solid @ini_border;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}

9
images/movie.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2" ry="2"/>
<line x1="2" y1="8" x2="22" y2="8"/>
<line x1="6" y1="2" x2="6" y2="8"/>
<line x1="10" y1="2" x2="10" y2="8"/>
<line x1="14" y1="2" x2="14" y2="8"/>
<line x1="18" y1="2" x2="18" y2="8"/>
<polygon points="10,12 10,19 16,15.5" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -51,19 +51,33 @@
function getCalendarStateKey(calendar) { function getCalendarStateKey(calendar) {
var baseNs = calendar.getAttribute('data-base-ns') || 'chronological'; var baseNs = calendar.getAttribute('data-base-ns') || 'chronological';
return 'luxtools.calendar.month.' + baseNs; return 'luxtools_calendar_month_' + baseNs.replace(/[^a-zA-Z0-9]/g, '_');
} }
function shouldPersistCalendarMonth(calendar) { function shouldPersistCalendarMonth(calendar) {
return (calendar.getAttribute('data-luxtools-size') || 'large') === 'small'; return (calendar.getAttribute('data-luxtools-size') || 'large') === 'small';
} }
function getCookieValue(name) {
var match = document.cookie.match('(^|;)\\s*' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*([^;]+)');
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value) {
var date = new Date();
date.setFullYear(date.getFullYear() + 1);
document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + date.toUTCString() + '; path=/; SameSite=Lax';
}
function deleteCookie(name) {
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax';
}
function readSavedCalendarMonth(calendar) { function readSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return null; if (!shouldPersistCalendarMonth(calendar)) return null;
if (!window.localStorage) return null;
try { try {
var raw = window.localStorage.getItem(getCalendarStateKey(calendar)); var raw = getCookieValue(getCalendarStateKey(calendar));
if (!raw) return null; if (!raw) return null;
var parsed = JSON.parse(raw); var parsed = JSON.parse(raw);
@@ -79,31 +93,17 @@
function saveCalendarMonth(calendar) { function saveCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return; if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return;
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10); var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10); var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
if (!year || month < 1 || month > 12) return; if (!year || month < 1 || month > 12) return;
try { setCookie(getCalendarStateKey(calendar), JSON.stringify({ year: year, month: month }));
window.localStorage.setItem(getCalendarStateKey(calendar), JSON.stringify({
year: year,
month: month
}));
} catch (e) {
// ignore storage failures
}
} }
function clearSavedCalendarMonth(calendar) { function clearSavedCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return; if (!shouldPersistCalendarMonth(calendar)) return;
if (!window.localStorage) return; deleteCookie(getCalendarStateKey(calendar));
try {
window.localStorage.removeItem(getCalendarStateKey(calendar));
} catch (e) {
// ignore storage failures
}
} }
function fetchCalendarMonth(calendar, year, month) { function fetchCalendarMonth(calendar, year, month) {
@@ -227,11 +227,22 @@
function restoreCalendarMonth(calendar) { function restoreCalendarMonth(calendar) {
if (!shouldPersistCalendarMonth(calendar)) return; if (!shouldPersistCalendarMonth(calendar)) return;
var saved = readSavedCalendarMonth(calendar);
if (!saved) return;
var year = parseInt(calendar.getAttribute('data-current-year') || '', 10); var year = parseInt(calendar.getAttribute('data-current-year') || '', 10);
var month = parseInt(calendar.getAttribute('data-current-month') || '', 10); var month = parseInt(calendar.getAttribute('data-current-month') || '', 10);
if (!year || !month) return;
var saved = readSavedCalendarMonth(calendar);
if (!saved) {
var now = new Date();
var todayYear = now.getFullYear();
var todayMonth = now.getMonth() + 1;
if (year !== todayYear || month !== todayMonth) {
loadCalendarMonth(calendar, todayYear, todayMonth, false);
}
return;
}
if (saved.year === year && saved.month === month) return; if (saved.year === year && saved.month === month) return;
loadCalendarMonth(calendar, saved.year, saved.month, true); loadCalendarMonth(calendar, saved.year, saved.month, true);
@@ -258,7 +269,17 @@
navigateCalendarMonth(calendar, direction, true); navigateCalendarMonth(calendar, direction, true);
} }
function updateClientDateCookie() {
var now = new Date();
setCookie('luxtools_client_month', JSON.stringify({
year: now.getFullYear(),
month: now.getMonth() + 1
}));
}
function initCalendarWidgets() { function initCalendarWidgets() {
updateClientDateCookie();
var calendars = document.querySelectorAll('div.luxtools-calendar[data-luxtools-calendar="1"]'); var calendars = document.querySelectorAll('div.luxtools-calendar[data-luxtools-calendar="1"]');
for (var i = 0; i < calendars.length; i++) { for (var i = 0; i < calendars.length; i++) {
syncCalendarToday(calendars[i]); syncCalendarToday(calendars[i]);

95
js/dialog.js Normal file
View File

@@ -0,0 +1,95 @@
/* global window, document */
/**
* Unified Dialog Infrastructure
*
* Provides a shared modal overlay and dialog container that all other
* client-side modules (event popups, cache purge, etc.) can use.
*
* Usage:
* Luxtools.Dialog.show(htmlString) render content and open
* Luxtools.Dialog.close() close the dialog
* Luxtools.Dialog.getContainer() return the dialog DOM element
*/
(function () {
'use strict';
var Luxtools = window.Luxtools || (window.Luxtools = {});
var Dialog = (function () {
var overlay = null;
var container = null;
/**
* Lazily create the overlay + dialog container and attach them to
* the document body. Wires up click-outside-to-close and Escape.
*/
function ensureElements() {
if (overlay) return;
overlay = document.createElement('div');
overlay.className = 'luxtools-dialog-overlay';
overlay.style.display = 'none';
container = document.createElement('div');
container.className = 'luxtools-dialog';
container.setAttribute('role', 'dialog');
container.setAttribute('aria-modal', 'true');
overlay.appendChild(container);
document.body.appendChild(overlay);
// Close when clicking the backdrop (but not the dialog itself)
overlay.addEventListener('click', function (e) {
if (e.target === overlay) close();
});
// Close on Escape
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay && overlay.style.display !== 'none') {
close();
}
});
}
/**
* Show the dialog with the given HTML content.
*
* The HTML should include a close button with class
* `luxtools-dialog-close` it will be wired up automatically.
*
* @param {string} html - innerHTML for the dialog container
*/
function show(html) {
ensureElements();
container.innerHTML = html;
overlay.style.display = 'flex';
// Auto-wire the close button inside the rendered content
var closeBtn = container.querySelector('.luxtools-dialog-close');
if (closeBtn) closeBtn.addEventListener('click', close);
}
/**
* Close (hide) the dialog.
*/
function close() {
if (overlay) overlay.style.display = 'none';
}
/**
* Return the dialog container element (creates it if necessary).
* Useful for querying form inputs after `show()`.
*
* @returns {HTMLElement}
*/
function getContainer() {
ensureElements();
return container;
}
return { show: show, close: close, getContainer: getContainer };
})();
Luxtools.Dialog = Dialog;
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/* global window, document */ /* global window, document */
(function () { (function () {
'use strict'; "use strict";
var Luxtools = window.Luxtools || (window.Luxtools = {}); var Luxtools = window.Luxtools || (window.Luxtools = {});
var Lightbox = Luxtools.Lightbox; var Lightbox = Luxtools.Lightbox;
@@ -16,7 +16,7 @@
function findOpenElement(target) { function findOpenElement(target) {
var el = target; var el = target;
while (el && el !== document) { while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-open')) return el; if (el.classList && el.classList.contains("luxtools-open")) return el;
el = el.parentNode; el = el.parentNode;
} }
return null; return null;
@@ -25,7 +25,8 @@
function findGalleryItem(target) { function findGalleryItem(target) {
var el = target; var el = target;
while (el && el !== document) { while (el && el !== document) {
if (el.classList && el.classList.contains('luxtools-gallery-item')) return el; if (el.classList && el.classList.contains("luxtools-gallery-item"))
return el;
el = el.parentNode; el = el.parentNode;
} }
return null; return null;
@@ -35,7 +36,9 @@
// Image gallery lightbox: intercept clicks so we don't navigate away. // Image gallery lightbox: intercept clicks so we don't navigate away.
var galleryItem = findGalleryItem(event.target); var galleryItem = findGalleryItem(event.target);
if (galleryItem && Lightbox && Lightbox.open) { if (galleryItem && Lightbox && Lightbox.open) {
var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null; var gallery = galleryItem.closest
? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]')
: null;
if (gallery) { if (gallery) {
event.preventDefault(); event.preventDefault();
Lightbox.open(gallery, galleryItem); Lightbox.open(gallery, galleryItem);
@@ -47,17 +50,16 @@
if (!el) return; if (!el) return;
// {{open>...}} renders as a link; avoid jumping to '#'. // {{open>...}} renders as a link; avoid jumping to '#'.
if (el.tagName && el.tagName.toLowerCase() === 'a') { if (el.tagName && el.tagName.toLowerCase() === "a") {
event.preventDefault(); event.preventDefault();
} }
var raw = el.getAttribute('data-path') || ''; var raw = el.getAttribute("data-path") || "";
if (!raw) return; if (!raw) return;
if (!OpenService || !OpenService.openViaService) return; if (!OpenService || !OpenService.openViaService) return;
// Prefer local client service. // Prefer local client service.
OpenService.openViaService(el, raw) OpenService.openViaService(el, raw).catch(function (err) {
.catch(function (err) {
// If the browser blocks the request before it reaches localhost (mixed-content, // If the browser blocks the request before it reaches localhost (mixed-content,
// extensions, stricter CORS handling), fall back to a no-CORS GET ping. // extensions, stricter CORS handling), fall back to a no-CORS GET ping.
if (OpenService && OpenService.pingOpenViaImage) { if (OpenService && OpenService.pingOpenViaImage) {
@@ -65,54 +67,42 @@
} }
// Fallback to old behavior (often blocked in modern browsers). // Fallback to old behavior (often blocked in modern browsers).
var url = OpenService && OpenService.normalizeToFileUrl ? OpenService.normalizeToFileUrl(raw) : ''; var url =
OpenService && OpenService.normalizeToFileUrl
? OpenService.normalizeToFileUrl(raw)
: "";
if (!url) return; if (!url) return;
console.warn('Local client service failed, falling back to file:// navigation:', err); console.warn(
"Local client service failed, falling back to file:// navigation:",
err,
);
try { try {
window.open(url, '_blank', 'noopener'); window.open(url, "_blank", "noopener");
} catch (e) { } catch (e) {
try { try {
window.location.href = url; window.location.href = url;
} catch (e2) { } catch (e2) {
console.error('Failed to open file URL:', e2); console.error("Failed to open file URL:", e2);
} }
} }
}); });
} }
function initChronologicalEventTimes() { function initChronologicalEventTimes() {
var nodes = document.querySelectorAll('.luxtools-event-time[data-luxtools-start]'); var nodes = document.querySelectorAll(
".luxtools-event-time[data-luxtools-start]",
);
if (!nodes || nodes.length === 0) return; if (!nodes || nodes.length === 0) return;
var formatter;
try {
formatter = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} catch (e) {
formatter = null;
}
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]; var node = nodes[i];
var raw = node.getAttribute('data-luxtools-start') || ''; var raw = node.getAttribute("data-luxtools-start") || "";
if (!raw) continue; if (!raw) continue;
var date = new Date(raw); var match = raw.match(/T(\d{2}):(\d{2})/);
if (isNaN(date.getTime())) continue; if (!match) continue;
var label; node.textContent = match[1] + ":" + match[2];
if (formatter) {
label = formatter.format(date);
} else {
var hh = String(date.getHours()).padStart(2, '0');
var mm = String(date.getMinutes()).padStart(2, '0');
label = hh + ':' + mm;
}
node.textContent = label;
} }
} }
@@ -120,71 +110,185 @@
// Purge Cache Dialog // Purge Cache Dialog
// ============================================================ // ============================================================
function initPurgeCacheDialog() { function initPurgeCacheDialog() {
var $link = jQuery('a.luxtools-invalidate-cache'); document.addEventListener(
if ($link.length === 0) return; "click",
function (e) {
var link = e.target.closest
? e.target.closest("a.luxtools-invalidate-cache")
: null;
if (!link) return;
$link.on('click.luxtools', function (e) {
e.preventDefault(); e.preventDefault();
var href = $link.attr('href') || ''; var href = link.getAttribute("href") || "";
var lang = (window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools) var lang =
window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools
? window.LANG.plugins.luxtools ? window.LANG.plugins.luxtools
: {}; : {};
var $dialog = jQuery( var html = '<div class="luxtools-dialog-content">';
'<div>' + html +=
'<p>' + (lang.cache_purge_dialog_intro || 'The DokuWiki cache will always be purged. Optionally also purge the luxtools-specific caches:') + '</p>' + '<button type="button" class="luxtools-dialog-close" aria-label="Close">&times;</button>';
'<p><label><input type="checkbox" id="luxtools-purge-pagelinks"> <strong>' + (lang.cache_purge_pagelinks_label || 'Pagelinks') + '</strong>' + html +=
' &ndash; ' + (lang.cache_purge_pagelinks_desc || 'Purges the pagelink mapping cache') + '</label></p>' + '<h3 class="luxtools-dialog-title">' +
'<p><label><input type="checkbox" id="luxtools-purge-thumbs"> <strong>' + (lang.cache_purge_thumbs_label || 'Thumbnails') + '</strong>' + (lang.cache_purge_dialog_title || "Purge Cache") +
' &ndash; ' + (lang.cache_purge_thumbs_desc || 'Purges all cached image thumbnails') + '</label></p>' + "</h3>";
'</div>' html +=
); "<p>" +
(lang.cache_purge_dialog_intro ||
"The DokuWiki cache will always be purged. Optionally also purge the luxtools-specific caches:") +
"</p>";
html +=
'<p><label><input type="checkbox" id="luxtools-purge-pagelinks"> <strong>' +
(lang.cache_purge_pagelinks_label || "Pagelinks") +
"</strong>";
html +=
" &ndash; " +
(lang.cache_purge_pagelinks_desc ||
"Purges the pagelink mapping cache") +
"</label></p>";
html +=
'<p><label><input type="checkbox" id="luxtools-purge-thumbs"> <strong>' +
(lang.cache_purge_thumbs_label || "Thumbnails") +
"</strong>";
html +=
" &ndash; " +
(lang.cache_purge_thumbs_desc ||
"Purges all cached image thumbnails") +
"</label></p>";
html += '<div class="luxtools-dialog-actions">';
html +=
'<button type="button" class="button luxtools-purge-confirm">' +
(lang.cache_purge_confirm || "Purge Cache") +
"</button> ";
html +=
'<button type="button" class="button luxtools-purge-cancel">' +
(lang.cache_purge_cancel || "Cancel") +
"</button>";
html += "</div>";
html += "</div>";
$dialog.dialog({ Luxtools.Dialog.show(html);
title: lang.cache_purge_dialog_title || 'Purge Cache',
modal: true, var container = Luxtools.Dialog.getContainer();
width: 420,
buttons: [ var cancelBtn = container.querySelector(".luxtools-purge-cancel");
{ if (cancelBtn) {
text: lang.cache_purge_cancel || 'Cancel', cancelBtn.addEventListener("click", function () {
click: function () { $dialog.dialog('close'); } Luxtools.Dialog.close();
}, });
{ }
text: lang.cache_purge_confirm || 'Purge Cache',
click: function () { var confirmBtn = container.querySelector(".luxtools-purge-confirm");
if (confirmBtn) {
confirmBtn.addEventListener("click", function () {
var url = href; var url = href;
if ($dialog.find('#luxtools-purge-pagelinks').prop('checked')) { var plCheck = container.querySelector("#luxtools-purge-pagelinks");
url += '&luxtools_purge_pagelinks=1'; var thCheck = container.querySelector("#luxtools-purge-thumbs");
if (plCheck && plCheck.checked) {
url += "&luxtools_purge_pagelinks=1";
} }
if ($dialog.find('#luxtools-purge-thumbs').prop('checked')) { if (thCheck && thCheck.checked) {
url += '&luxtools_purge_thumbs=1'; url += "&luxtools_purge_thumbs=1";
} }
$dialog.dialog('close'); Luxtools.Dialog.close();
window.location.href = url; window.location.href = url;
}
}
],
close: function () {
jQuery(this).dialog('destroy').remove();
}
});
}); });
} }
},
false,
);
}
// ============================================================
// Calendar Sync Button (syntax widget)
// ============================================================
function initCalendarSyncButtons() {
document.addEventListener(
"click",
function (e) {
var btn = e.target;
if (
!btn ||
!btn.classList ||
!btn.classList.contains("luxtools-calendar-sync-btn")
)
return;
e.preventDefault();
var ajaxUrl = btn.getAttribute("data-luxtools-ajax-url") || "";
var sectok = btn.getAttribute("data-luxtools-sectok") || "";
if (!ajaxUrl) return;
var status = btn.parentNode
? btn.parentNode.querySelector(".luxtools-calendar-sync-status")
: null;
btn.disabled = true;
if (status) {
status.textContent = "Syncing...";
status.style.color = "";
}
var xhr = new XMLHttpRequest();
xhr.open("POST", ajaxUrl, true);
xhr.setRequestHeader(
"Content-Type",
"application/x-www-form-urlencoded",
);
xhr.onload = function () {
btn.disabled = false;
try {
var r = JSON.parse(xhr.responseText);
if (status) {
status.textContent = r.message || (r.ok ? "Done" : "Failed");
status.style.color = r.ok ? "green" : "red";
}
} catch (ex) {
if (status) {
status.textContent = "Error";
status.style.color = "red";
}
}
};
xhr.onerror = function () {
btn.disabled = false;
if (status) {
status.textContent = "Network error";
status.style.color = "red";
}
};
xhr.send(
"call=luxtools_calendar_sync&sectok=" + encodeURIComponent(sectok),
);
},
false,
);
}
// ============================================================ // ============================================================
// Initialize // Initialize
// ============================================================ // ============================================================
document.addEventListener('click', onClick, false); document.addEventListener("click", onClick, false);
document.addEventListener('DOMContentLoaded', function () { document.addEventListener(
"DOMContentLoaded",
function () {
if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init(); if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init();
initChronologicalEventTimes(); initChronologicalEventTimes();
if (CalendarWidget && CalendarWidget.init) CalendarWidget.init(); if (CalendarWidget && CalendarWidget.init) CalendarWidget.init();
initPurgeCacheDialog(); initPurgeCacheDialog();
}, false); initCalendarSyncButtons();
document.addEventListener('DOMContentLoaded', function () { },
false,
);
document.addEventListener(
"DOMContentLoaded",
function () {
if (Scratchpads && Scratchpads.init) Scratchpads.init(); if (Scratchpads && Scratchpads.init) Scratchpads.init();
}, false); },
false,
);
Luxtools.initChronologicalEventTimes = initChronologicalEventTimes; Luxtools.initChronologicalEventTimes = initChronologicalEventTimes;
})(); })();

206
js/movie-import.js Normal file
View File

@@ -0,0 +1,206 @@
/* global window, jQuery */
/**
* Movie Import toolbar button for luxtools.
*
* Fetches movie metadata from OMDb (client-side) and inserts/replaces
* a wiki markup block in the editor.
*
* The OMDb API key is intentionally passed to the browser via JSINFO.
* It will be visible in browser developer tools and network requests.
* This is an accepted tradeoff for this single-user LAN deployment.
*/
(function () {
'use strict';
var MARKER_BEGIN = '<!-- BEGIN MOVIE -->';
var MARKER_END = '<!-- END MOVIE -->';
/**
* Get the OMDb API key from JSINFO (set by PHP in action.php).
*/
function getApiKey() {
if (window.JSINFO && window.JSINFO.luxtools_omdb_apikey) {
return String(window.JSINFO.luxtools_omdb_apikey);
}
return '';
}
/**
* Get localized string from DokuWiki's LANG object.
*/
function lang(key, fallback) {
if (window.LANG && window.LANG.plugins && window.LANG.plugins.luxtools &&
window.LANG.plugins.luxtools[key]) {
return String(window.LANG.plugins.luxtools[key]);
}
return fallback || key;
}
/**
* Extract the first heading from the editor text.
* DokuWiki headings use ====== Heading ====== syntax.
* Returns the heading text trimmed, or empty string.
*/
function extractFirstHeading(text) {
var match = text.match(/^={2,6}\s*(.+?)\s*={2,6}\s*$/m);
if (match && match[1]) {
return match[1].trim();
}
return '';
}
/**
* Parse a title string that may contain a trailing year in parentheses.
* e.g. "Project Hail Mary (2026)" -> { title: "Project Hail Mary", year: "2026" }
* e.g. "Inception" -> { title: "Inception", year: null }
*/
function parseTitleYear(raw) {
var match = raw.match(/^(.+?)\s*\((\d{4})\)\s*$/);
if (match) {
return { title: match[1].trim(), year: match[2] };
}
return { title: raw.trim(), year: null };
}
/**
* Fetch movie data from OMDb.
* Returns a Promise that resolves with the parsed JSON response.
*/
function fetchMovie(apiKey, title, year) {
var url = 'https://www.omdbapi.com/?apikey=' + encodeURIComponent(apiKey) +
'&type=movie&t=' + encodeURIComponent(title);
if (year) {
url += '&y=' + encodeURIComponent(year);
}
return jQuery.ajax({
url: url,
dataType: 'json',
timeout: 10000
});
}
/**
* Build the wiki markup block for a movie.
*/
function buildMarkup(movie) {
var lines = [];
lines.push(MARKER_BEGIN);
// Poster image (omit if N/A or empty)
var poster = movie.Poster || '';
if (poster && poster !== 'N/A') {
lines.push('{{image>' + poster + '}}');
}
// Data table
lines.push('^ Title | ' + safe(movie.Title) + ' |');
lines.push('^ Year | ' + safe(movie.Year) + ' |');
lines.push('^ Runtime | ' + safe(movie.Runtime) + ' |');
lines.push('^ Genre | ' + safe(movie.Genre) + ' |');
lines.push('^ Director | ' + safe(movie.Director) + ' |');
lines.push('^ Actors | ' + safe(movie.Actors) + ' |');
lines.push('^ Plot | ' + safe(movie.Plot) + ' |');
lines.push(MARKER_END);
return lines.join('\n');
}
/**
* Return value for table cell, replacing N/A with empty string.
*/
function safe(val) {
if (!val || val === 'N/A') return '';
return String(val);
}
/**
* Get the editor textarea element by id.
*/
function getEditor(edid) {
var id = edid || 'wiki__text';
return document.getElementById(id);
}
/**
* Main import action: prompt for title, fetch from OMDb, insert/replace markup.
*/
function importMovie(edid) {
var apiKey = getApiKey();
if (!apiKey) {
alert(lang('movie_error_no_apikey', 'OMDb API key is not configured. Set it in Admin → luxtools.'));
return;
}
var editor = getEditor(edid);
if (!editor) return;
var text = editor.value || '';
var heading = extractFirstHeading(text);
var defaultTitle = heading || '';
var input = prompt(lang('movie_prompt', 'Enter movie title (optionally with year):'), defaultTitle);
if (input === null || input.trim() === '') return;
var parsed = parseTitleYear(input.trim());
fetchMovie(apiKey, parsed.title, parsed.year)
.done(function (data) {
if (!data || data.Response === 'False') {
var errMsg = (data && data.Error) ? data.Error : lang('movie_error_not_found', 'Movie not found.');
alert(errMsg);
return;
}
var markup = buildMarkup(data);
insertOrReplace(editor, markup);
})
.fail(function () {
alert(lang('movie_error_fetch', 'OMDb lookup failed.'));
});
}
/**
* Insert movie markup into the editor, replacing an existing movie block if present.
*/
function insertOrReplace(editor, markup) {
var text = editor.value || '';
var beginIdx = text.indexOf(MARKER_BEGIN);
var endIdx = text.indexOf(MARKER_END);
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
// Replace existing block
var before = text.substring(0, beginIdx);
var after = text.substring(endIdx + MARKER_END.length);
editor.value = before + markup + after;
} else {
// Append after the first heading line, or at the end
var headingMatch = text.match(/^(={2,6}\s*.+?\s*={2,6}\s*)$/m);
if (headingMatch) {
var headingEnd = text.indexOf(headingMatch[0]) + headingMatch[0].length;
editor.value = text.substring(0, headingEnd) + '\n\n' + markup + text.substring(headingEnd);
} else {
editor.value = text + '\n\n' + markup;
}
}
// Trigger input event so DokuWiki knows the content changed
try {
editor.dispatchEvent(new Event('input', { bubbles: true }));
} catch (e) {
// IE fallback
}
}
// Register toolbar button action handler.
// DokuWiki toolbar looks for window.addBtnAction<Type> for custom button types.
window.addBtnActionLuxtoolsMovieImport = function ($btn, props, edid) {
$btn.on('click', function () {
importMovie(edid);
return false;
});
return 'luxtools-movie-import';
};
})();

View File

@@ -95,14 +95,6 @@ $lang["pagelink_unlinked"] = "Seite nicht verknüpft";
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft"; $lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
$lang["chronological_photos_title"] = "Fotos"; $lang["chronological_photos_title"] = "Fotos";
$lang["chronological_events_title"] = "Termine"; $lang["chronological_events_title"] = "Termine";
$lang["chronological_maintenance_title"] = "Aufgaben";
$lang["maintenance_task_complete"] = "Erledigen";
$lang["maintenance_task_reopen"] = "Wieder öffnen";
$lang["maintenance_no_tasks"] = "Keine offenen Aufgaben.";
$lang["maintenance_complete_success"] = "Aufgabe als erledigt markiert.";
$lang["maintenance_complete_error"] = "Aktualisierung der Aufgabe fehlgeschlagen.";
$lang["maintenance_reopen_success"] = "Aufgabe wieder geöffnet.";
$lang["maintenance_remote_write_failed"] = "Lokale Aktualisierung erfolgreich, aber CalDAV-Update fehlgeschlagen. Wird bei nächster Synchronisierung erneut versucht.";
$lang["calendar_sync_button"] = "Kalender synchronisieren"; $lang["calendar_sync_button"] = "Kalender synchronisieren";
$lang["calendar_sync_success"] = "Kalender-Synchronisierung abgeschlossen."; $lang["calendar_sync_success"] = "Kalender-Synchronisierung abgeschlossen.";
$lang["calendar_sync_error"] = "Kalender-Synchronisierung fehlgeschlagen."; $lang["calendar_sync_error"] = "Kalender-Synchronisierung fehlgeschlagen.";
@@ -122,3 +114,20 @@ $lang["cache_purge_cancel"] = "Abbrechen";
$lang["cache_purge_confirm"] = "Cache leeren"; $lang["cache_purge_confirm"] = "Cache leeren";
$lang["cache_purge_pagelinks_success"] = "Seitenlink-Cache geleert."; $lang["cache_purge_pagelinks_success"] = "Seitenlink-Cache geleert.";
$lang["cache_purge_thumbs_success"] = "Vorschaubild-Cache geleert."; $lang["cache_purge_thumbs_success"] = "Vorschaubild-Cache geleert.";
$lang["toolbar_movie_title"] = "Film-Import";
$lang["movie_prompt"] = "Filmtitel eingeben (optional mit Jahr):";
$lang["movie_error_no_apikey"] = "OMDb-API-Schlüssel nicht konfiguriert. Unter Admin → luxtools einstellen.";
$lang["movie_error_not_found"] = "Film nicht gefunden.";
$lang["movie_error_fetch"] = "OMDb-Abfrage fehlgeschlagen.";
$lang["omdb_heading"] = "Film-Import (OMDb)";
$lang["omdb_apikey"] = "OMDb-API-Schlüssel";
$lang["omdb_apikey_note"] = "Der API-Schlüssel wird an den Browser übergeben für clientseitige OMDb-Abfragen. Er ist in den Browser-Entwicklertools und Netzwerkanfragen sichtbar.";
$lang["event_create_success"] = "Termin erstellt.";
$lang["event_create_error"] = "Termin konnte nicht erstellt werden.";
$lang["event_edit_success"] = "Termin aktualisiert.";
$lang["event_edit_error"] = "Termin konnte nicht aktualisiert werden.";
$lang["event_delete_success"] = "Termin gelöscht.";
$lang["event_delete_error"] = "Termin konnte nicht gelöscht werden.";
$lang["event_caldav_push_failed"] = "Lokale Aktualisierung erfolgreich, aber CalDAV-Upload fehlgeschlagen.";

View File

@@ -28,3 +28,5 @@ $lang["gallery_thumb_scale"] = "Skalierungsfaktor fuer Galerie-Thumbnails. 2 erz
$lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765)."; $lang["open_service_url"] = "URL des lokalen Client-Dienstes fuer {{open>...}} (z.B. http://127.0.0.1:8765).";
$lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root)."; $lang["pagelink_search_depth"] = "Maximale Verzeichnisebene fuer .pagelink-Suche (0 = nur Root).";
$lang["omdb_apikey"] = "OMDb-API-Schlüssel für den Film-Import. Wird clientseitig im Browser verwendet.";

View File

@@ -96,14 +96,6 @@ $lang["pagelink_multi_warning"] = "Multiple folders linked";
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM."; $lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
$lang["chronological_photos_title"] = "Photos"; $lang["chronological_photos_title"] = "Photos";
$lang["chronological_events_title"] = "Events"; $lang["chronological_events_title"] = "Events";
$lang["chronological_maintenance_title"] = "Tasks";
$lang["maintenance_task_complete"] = "Complete";
$lang["maintenance_task_reopen"] = "Reopen";
$lang["maintenance_no_tasks"] = "No open tasks.";
$lang["maintenance_complete_success"] = "Task marked as completed.";
$lang["maintenance_complete_error"] = "Failed to update task.";
$lang["maintenance_reopen_success"] = "Task reopened.";
$lang["maintenance_remote_write_failed"] = "Local update succeeded, but remote CalDAV update failed. Will retry on next sync.";
$lang["calendar_sync_button"] = "Sync Calendars"; $lang["calendar_sync_button"] = "Sync Calendars";
$lang["calendar_sync_success"] = "Calendar sync completed."; $lang["calendar_sync_success"] = "Calendar sync completed.";
$lang["calendar_sync_error"] = "Calendar sync failed."; $lang["calendar_sync_error"] = "Calendar sync failed.";
@@ -123,3 +115,20 @@ $lang["cache_purge_cancel"] = "Cancel";
$lang["cache_purge_confirm"] = "Purge Cache"; $lang["cache_purge_confirm"] = "Purge Cache";
$lang["cache_purge_pagelinks_success"] = "Pagelinks cache purged."; $lang["cache_purge_pagelinks_success"] = "Pagelinks cache purged.";
$lang["cache_purge_thumbs_success"] = "Thumbnail cache purged."; $lang["cache_purge_thumbs_success"] = "Thumbnail cache purged.";
$lang["toolbar_movie_title"] = "Movie Import";
$lang["movie_prompt"] = "Enter movie title (optionally with year):";
$lang["movie_error_no_apikey"] = "OMDb API key is not configured. Set it in Admin → luxtools.";
$lang["movie_error_not_found"] = "Movie not found.";
$lang["movie_error_fetch"] = "OMDb lookup failed.";
$lang["omdb_heading"] = "Movie Import (OMDb)";
$lang["omdb_apikey"] = "OMDb API key";
$lang["omdb_apikey_note"] = "The API key is passed to the browser for client-side OMDb lookups. It will be visible in browser developer tools and network requests.";
$lang["event_create_success"] = "Event created.";
$lang["event_create_error"] = "Failed to create event.";
$lang["event_edit_success"] = "Event updated.";
$lang["event_edit_error"] = "Failed to update event.";
$lang["event_delete_success"] = "Event deleted.";
$lang["event_delete_error"] = "Failed to delete event.";
$lang["event_caldav_push_failed"] = "Local update succeeded, but CalDAV upload failed.";

View File

@@ -28,3 +28,5 @@ $lang['gallery_thumb_scale'] = 'Gallery thumbnail scale factor. Use 2 for sharpe
$lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).'; $lang['open_service_url'] = 'Local client service URL for the {{open>...}} link (e.g. http://127.0.0.1:8765).';
$lang['pagelink_search_depth'] = 'Maximum directory depth for .pagelink search (0 = only root).'; $lang['pagelink_search_depth'] = 'Maximum directory depth for .pagelink search (0 = only root).';
$lang['omdb_apikey'] = 'OMDb API key for movie metadata import. Used client-side in the browser.';

View File

@@ -395,6 +395,68 @@ class CalDavClient
} }
} }
/**
* Public wrapper for findObjectByUid.
*
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $uid
* @return array{href: string, etag: string, data: string}|null
*/
public static function findObjectByUidPublic(
string $caldavUrl,
string $username,
string $password,
string $uid
): ?array {
return self::findObjectByUid($caldavUrl, $username, $password, $uid);
}
/**
* Public wrapper for putCalendarObject.
*
* @param string $href
* @param string $username
* @param string $password
* @param string $data
* @param string $etag
* @return string Empty string on success, error on failure
*/
public static function putCalendarObjectPublic(
string $href,
string $username,
string $password,
string $data,
string $etag
): string {
return self::putCalendarObject($href, $username, $password, $data, $etag);
}
/**
* Delete a calendar object from the server.
*
* @param string $href Full URL of the calendar object
* @param string $username
* @param string $password
* @param string $etag ETag for If-Match header (empty to skip)
* @return bool True on success
*/
public static function deleteCalendarObject(
string $href,
string $username,
string $password,
string $etag
): bool {
$headers = [];
if ($etag !== '') {
$headers[] = 'If-Match: ' . $etag;
}
$response = self::request('DELETE', $href, $username, $password, '', $headers);
return $response !== null;
}
/** /**
* Perform an HTTP request to a CalDAV server. * Perform an HTTP request to a CalDAV server.
* *

View File

@@ -10,7 +10,7 @@ namespace dokuwiki\plugin\luxtools;
*/ */
class CalendarEvent class CalendarEvent
{ {
/** @var string Calendar slot key (e.g. 'general', 'maintenance') */ /** @var string Calendar slot key (e.g. 'general', 'slot2') */
public $slotKey; public $slotKey;
/** @var string Unique source event UID */ /** @var string Unique source event UID */
@@ -52,35 +52,4 @@ class CalendarEvent
/** @var string The date (YYYY-MM-DD) this event applies to */ /** @var string The date (YYYY-MM-DD) this event applies to */
public $dateIso; public $dateIso;
/**
* Build a stable completion key for maintenance task tracking.
*
* @return string
*/
public function completionKey(): string
{
return implode('|', [$this->slotKey, $this->uid, $this->dateIso]);
}
/**
* Whether this event/task is marked as completed.
*
* @return bool
*/
public function isCompleted(): bool
{
$s = strtoupper($this->status);
return $s === 'COMPLETED';
}
/**
* Whether this event/task is open (for maintenance filtering).
*
* @return bool
*/
public function isOpen(): bool
{
return !$this->isCompleted();
}
} }

View File

@@ -14,17 +14,13 @@ use Throwable;
/** /**
* Slot-aware calendar service. * Slot-aware calendar service.
* *
* Provides normalized event data grouped by slot for rendering, * Provides normalized event data grouped by slot for rendering and widget indicators.
* widget indicators, task list queries, and completion tracking.
*/ */
class CalendarService class CalendarService
{ {
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */ /** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
protected static $dayCache = []; protected static $dayCache = [];
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
protected static $taskCache = [];
/** @var array<string,VCalendar|null> In-request cache keyed by file path */ /** @var array<string,VCalendar|null> In-request cache keyed by file path */
protected static $vcalCache = []; protected static $vcalCache = [];
@@ -111,81 +107,6 @@ class CalendarService
return self::slotEventsForDate($slot, $dateIso) !== []; return self::slotEventsForDate($slot, $dateIso) !== [];
} }
/**
* Get all open maintenance tasks due up to (and including) today.
*
* @param CalendarSlot $maintenanceSlot
* @param string $todayIso YYYY-MM-DD
* @param int $pastDays Maximum number of overdue days to include
* @return CalendarEvent[] Sorted: overdue first, then today, then by title
*/
public static function openMaintenanceTasks(CalendarSlot $maintenanceSlot, string $todayIso, int $pastDays = 30): array
{
if (!$maintenanceSlot->isEnabled()) return [];
if (!ChronoID::isIsoDate($todayIso)) return [];
$pastDays = max(0, $pastDays);
$file = $maintenanceSlot->getFile();
if ($file === '' || !is_file($file) || !is_readable($file)) return [];
$cacheKey = $maintenanceSlot->getKey() . '|tasks|' . $todayIso . '|' . $pastDays;
if (isset(self::$taskCache[$cacheKey])) {
return self::$taskCache[$cacheKey];
}
$tasks = self::parseAllTasksFromFile($file, $maintenanceSlot->getKey(), $todayIso);
$oldestOpenDateIso = self::oldestOpenTaskDate($todayIso, $pastDays);
// Filter: only non-completed, due today or earlier, and not older than the configured overdue window.
$open = [];
foreach ($tasks as $task) {
if ($task->isCompleted()) continue;
if ($task->dateIso > $todayIso) continue;
if ($task->dateIso < $oldestOpenDateIso) continue;
$open[] = $task;
}
// Sort: overdue first, then today, then by time, then by title
usort($open, static function (CalendarEvent $a, CalendarEvent $b) use ($todayIso): int {
$aOverdue = $a->dateIso < $todayIso;
$bOverdue = $b->dateIso < $todayIso;
if ($aOverdue !== $bOverdue) {
return $aOverdue ? -1 : 1;
}
$dateCmp = strcmp($a->dateIso, $b->dateIso);
if ($dateCmp !== 0) return $dateCmp;
$timeCmp = strcmp($a->time, $b->time);
if ($timeCmp !== 0) return $timeCmp;
return strcmp($a->summary, $b->summary);
});
self::$taskCache[$cacheKey] = $open;
return $open;
}
/**
* @param string $todayIso
* @param int $pastDays
* @return string
*/
protected static function oldestOpenTaskDate(string $todayIso, int $pastDays): string
{
try {
$today = new DateTimeImmutable($todayIso . ' 00:00:00', new DateTimeZone('UTC'));
} catch (Throwable $e) {
return $todayIso;
}
if ($pastDays === 0) {
return $today->format('Y-m-d');
}
return $today->sub(new DateInterval('P' . $pastDays . 'D'))->format('Y-m-d');
}
/** /**
* Get slot-level day indicator data for a whole month. * Get slot-level day indicator data for a whole month.
* *
@@ -217,12 +138,13 @@ class CalendarService
if ($calendar === null) continue; if ($calendar === null) continue;
try { try {
$uidTimezones = self::buildUidTimezoneMap($calendar);
$expanded = $calendar->expand($rangeStart, $rangeEnd); $expanded = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) continue; if (!($expanded instanceof VCalendar)) continue;
for ($day = 1; $day <= $daysInMonth; $day++) { for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day); $dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso); $events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso, $uidTimezones);
$cacheKey = $slot->getKey() . '|' . $dateIso; $cacheKey = $slot->getKey() . '|' . $dateIso;
self::$dayCache[$cacheKey] = $events; self::$dayCache[$cacheKey] = $events;
@@ -301,6 +223,28 @@ class CalendarService
} }
} }
/**
* Build a UID → TZID map from the original (non-expanded) calendar.
*
* VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing
* the original TZID. This map lets us restore the correct timezone afterwards.
*
* @param VCalendar $calendar
* @return array<string,string> uid => tzid
*/
protected static function buildUidTimezoneMap(VCalendar $calendar): array
{
$map = [];
foreach ($calendar->select('VEVENT') as $vevent) {
if (!isset($vevent->DTSTART)) continue;
$uid = trim((string)($vevent->UID ?? ''));
if ($uid === '' || isset($map[$uid])) continue;
$tzid = (string)($vevent->DTSTART['TZID'] ?? '');
if ($tzid !== '') $map[$uid] = $tzid;
}
return $map;
}
/** /**
* Parse events from a local ICS file for a specific date. * Parse events from a local ICS file for a specific date.
* *
@@ -315,6 +259,8 @@ class CalendarService
if ($calendar === null) return []; if ($calendar === null) return [];
try { try {
$uidTimezones = self::buildUidTimezoneMap($calendar);
$utc = new DateTimeZone('UTC'); $utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc); $rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D')); $rangeStart = $rangeStart->sub(new DateInterval('P1D'));
@@ -323,50 +269,7 @@ class CalendarService
$expanded = $calendar->expand($rangeStart, $rangeEnd); $expanded = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return []; if (!($expanded instanceof VCalendar)) return [];
return self::collectFromCalendar($expanded, $slotKey, $dateIso); return self::collectFromCalendar($expanded, $slotKey, $dateIso, $uidTimezones);
} catch (Throwable $e) {
return [];
}
}
/**
* Parse all tasks (VEVENT with STATUS) from a maintenance file,
* expanding recurrences up to the given date.
*
* @param string $file
* @param string $slotKey
* @param string $todayIso
* @return CalendarEvent[]
*/
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
{
$calendar = self::readCalendar($file);
if ($calendar === null) return [];
try {
$component = $calendar;
// Expand from a reasonable lookback to tomorrow
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable('2020-01-01 00:00:00', $utc);
$rangeEnd = new DateTimeImmutable($todayIso . ' 00:00:00', $utc);
$rangeEnd = $rangeEnd->add(new DateInterval('P1D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
$tasks = [];
// Collect VEVENTs
foreach ($expanded->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEvent($vevent, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
return $tasks;
} catch (Throwable $e) { } catch (Throwable $e) {
return []; return [];
} }
@@ -378,9 +281,10 @@ class CalendarService
* @param VCalendar $calendar * @param VCalendar $calendar
* @param string $slotKey * @param string $slotKey
* @param string $dateIso * @param string $dateIso
* @param array<string,string> $uidTimezones uid => tzid, for restoring timezone after expand()
* @return CalendarEvent[] * @return CalendarEvent[]
*/ */
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso, array $uidTimezones = []): array
{ {
$result = []; $result = [];
$seen = []; $seen = [];
@@ -388,7 +292,7 @@ class CalendarService
// VEVENTs // VEVENTs
foreach ($calendar->select('VEVENT') as $vevent) { foreach ($calendar->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue; if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso); $event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso, $uidTimezones);
if ($event === null) continue; if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary; $dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
@@ -407,9 +311,10 @@ class CalendarService
* @param VEvent $vevent * @param VEvent $vevent
* @param string $slotKey * @param string $slotKey
* @param string $dateIso * @param string $dateIso
* @param array<string,string> $uidTimezones uid => tzid, for restoring timezone after expand()
* @return CalendarEvent|null * @return CalendarEvent|null
*/ */
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso, array $uidTimezones = []): ?CalendarEvent
{ {
if (!isset($vevent->DTSTART)) return null; if (!isset($vevent->DTSTART)) return null;
@@ -417,8 +322,30 @@ class CalendarService
$start = self::toImmutable($vevent->DTSTART->getDateTime()); $start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null; if ($start === null) return null;
// VCalendar::expand() normalizes all timezone-aware datetimes to UTC, losing
// the original TZID. Restore it using the pre-expansion UID→TZID map so that
// display times and day-boundary checks use the event's original timezone.
$restoredTz = null;
if (!$isAllDay && $uidTimezones !== [] && $start->getTimezone()->getName() === 'UTC') {
$uid = trim((string)($vevent->UID ?? ''));
$tzid = $uidTimezones[$uid] ?? '';
if ($tzid !== '') {
try {
$restoredTz = new DateTimeZone($tzid);
$start = $start->setTimezone($restoredTz);
} catch (Throwable $e) {
$restoredTz = null;
}
}
}
$end = self::resolveEnd($vevent, $start, $isAllDay); $end = self::resolveEnd($vevent, $start, $isAllDay);
// Apply the same timezone restoration to the end datetime.
if ($restoredTz !== null && $end->getTimezone()->getName() === 'UTC') {
$end = $end->setTimezone($restoredTz);
}
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null; if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
$event = new CalendarEvent(); $event = new CalendarEvent();
@@ -440,42 +367,6 @@ class CalendarService
return $event; return $event;
} }
/**
* Normalize a VEVENT into a CalendarEvent (without day filtering).
*
* @param VEvent $vevent
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVEvent(VEvent $vevent, string $slotKey): ?CalendarEvent
{
if (!isset($vevent->DTSTART)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = self::resolveEnd($vevent, $start, $isAllDay);
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vevent->UID ?? ''));
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $start->format(DateTimeInterface::ATOM);
$event->endIso = $end->format(DateTimeInterface::ATOM);
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $start->format('H:i');
$event->location = trim((string)($vevent->LOCATION ?? ''));
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
$event->componentType = 'VEVENT';
$event->dateIso = $start->format('Y-m-d');
return $event;
}
/** /**
* Resolve the end date/time for a VEVENT. * Resolve the end date/time for a VEVENT.
* *
@@ -573,7 +464,6 @@ class CalendarService
public static function clearCache(): void public static function clearCache(): void
{ {
self::$dayCache = []; self::$dayCache = [];
self::$taskCache = [];
self::$vcalCache = []; self::$vcalCache = [];
} }
} }

View File

@@ -12,7 +12,7 @@ namespace dokuwiki\plugin\luxtools;
class CalendarSlot class CalendarSlot
{ {
/** @var string[] Ordered list of all supported slot keys */ /** @var string[] Ordered list of all supported slot keys */
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4']; public const SLOT_KEYS = ['general', 'slot2', 'slot3', 'slot4'];
/** @var string[] Allowed widget indicator display positions */ /** @var string[] Allowed widget indicator display positions */
public const INDICATOR_DISPLAYS = ['none', 'top-left', 'top-right', 'bottom-left', 'bottom-right']; public const INDICATOR_DISPLAYS = ['none', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
@@ -20,7 +20,7 @@ class CalendarSlot
/** @var array<string,string> Human-readable labels for slot keys */ /** @var array<string,string> Human-readable labels for slot keys */
public const SLOT_LABELS = [ public const SLOT_LABELS = [
'general' => 'General', 'general' => 'General',
'maintenance' => 'Maintenance', 'slot2' => 'Slot 2',
'slot3' => 'Slot 3', 'slot3' => 'Slot 3',
'slot4' => 'Slot 4', 'slot4' => 'Slot 4',
]; ];

View File

@@ -0,0 +1,40 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Centralized calendar sync service.
*
* Extracts sync logic from action.php so it can be reused
* by the admin page, the calendar_sync syntax, and future
* CLI entry points without duplicating code.
*/
class CalendarSyncService
{
/**
* Run a full sync for all enabled slots that have a remote source.
*
* @param CalendarSlot[] $slots Keyed by slot key
* @return array{ok: bool, message: string, results: array<string,bool>}
*/
public static function syncAll(array $slots): array
{
$results = [];
$hasErrors = false;
foreach ($slots as $slot) {
if (!$slot->hasRemoteSource()) continue;
$ok = CalDavClient::syncSlot($slot);
$results[$slot->getKey()] = $ok;
if (!$ok) $hasErrors = true;
}
CalendarService::clearCache();
return [
'ok' => !$hasErrors,
'results' => $results,
];
}
}

View File

@@ -132,7 +132,13 @@ class ChronologicalCalendarWidget
$classes .= ' luxtools-calendar-day-has-events'; $classes .= ' luxtools-calendar-day-has-events';
} }
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">'; // Encode day event data as JSON for the day popup
$dayEventsJson = self::encodeDayEventsJson($events);
$dayDataAttr = $dayEventsJson !== '[]'
? ' data-luxtools-day-events="' . hsc($dayEventsJson) . '"'
: '';
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '" data-luxtools-day="1"' . $dayDataAttr . '>';
if ($size === 'small') { if ($size === 'small') {
$dayIndicators = $indicators[$date] ?? []; $dayIndicators = $indicators[$date] ?? [];
@@ -277,7 +283,47 @@ class ChronologicalCalendarWidget
} }
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"'; $attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"'; $attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
if ($event->uid !== '') {
$attrs .= ' data-event-uid="' . hsc($event->uid) . '"';
}
if ($event->recurrenceId !== '') {
$attrs .= ' data-event-recurrence="' . hsc($event->recurrenceId) . '"';
}
if ($event->dateIso !== '') {
$attrs .= ' data-event-date="' . hsc($event->dateIso) . '"';
}
return $attrs; return $attrs;
} }
/**
* Encode day events as a JSON array for the day popup.
*
* @param CalendarEvent[] $events
* @return string JSON string
*/
protected static function encodeDayEventsJson(array $events): string
{
if ($events === []) return '[]';
$items = [];
foreach ($events as $event) {
$item = [
'summary' => $event->summary,
'start' => $event->startIso,
'allDay' => $event->allDay,
'slot' => $event->slotKey,
];
if ($event->endIso !== '') $item['end'] = $event->endIso;
if ($event->location !== '') $item['location'] = $event->location;
if ($event->description !== '') $item['description'] = $event->description;
if ($event->time !== '') $item['time'] = $event->time;
if ($event->uid !== '') $item['uid'] = $event->uid;
if ($event->recurrenceId !== '') $item['recurrence'] = $event->recurrenceId;
if ($event->dateIso !== '') $item['date'] = $event->dateIso;
$items[] = $item;
}
return json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT);
}
} }

View File

@@ -265,6 +265,459 @@ class IcsWriter
return self::atomicWrite($filePath, $content); return self::atomicWrite($filePath, $content);
} }
/**
* Create a new event in a local ICS file.
*
* @param string $filePath
* @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
* @return string The UID of the created event, or empty string on failure
*/
public static function createEvent(string $filePath, array $eventData): string
{
if ($filePath === '') return '';
try {
$calendar = null;
if (is_file($filePath) && is_readable($filePath)) {
$raw = @file_get_contents($filePath);
if (is_string($raw) && trim($raw) !== '') {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) {
$calendar = null;
}
}
}
if ($calendar === null) {
$calendar = new VCalendar();
$calendar->PRODID = '-//LuxTools DokuWiki Plugin//EN';
}
$uid = self::generateUid();
$props = [
'UID' => $uid,
'SUMMARY' => $eventData['summary'] ?? '',
'DTSTAMP' => gmdate('Ymd\THis\Z'),
];
if (!empty($eventData['location'])) {
$props['LOCATION'] = $eventData['location'];
}
if (!empty($eventData['description'])) {
$props['DESCRIPTION'] = $eventData['description'];
}
$dateIso = $eventData['date'] ?? '';
$allDay = !empty($eventData['allDay']);
if ($allDay) {
$props['DTSTART'] = str_replace('-', '', $dateIso);
$vevent = $calendar->add('VEVENT', $props);
$vevent->DTSTART['VALUE'] = 'DATE';
} else {
$startTime = $eventData['startTime'] ?? '00:00';
$endTime = $eventData['endTime'] ?? '';
$props['DTSTART'] = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
$vevent = $calendar->add('VEVENT', $props);
if ($endTime !== '') {
$vevent->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
}
}
$output = $calendar->serialize();
if (!self::atomicWritePublic($filePath, $output)) {
return '';
}
return $uid;
} catch (Throwable $e) {
return '';
}
}
/**
* Edit an existing event in a local ICS file.
*
* Scope controls how recurring event edits are applied:
* - 'all': modify the master event directly
* - 'this': create/update an occurrence override
* - 'future': truncate the master's RRULE before this date and create a new series
*
* @param string $filePath
* @param string $uid
* @param string $recurrenceId
* @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData
* @param string $scope 'all', 'this', or 'future'
* @return bool
*/
public static function editEvent(string $filePath, string $uid, string $recurrenceId, array $eventData, string $scope = 'all'): bool
{
if ($filePath === '' || $uid === '') return false;
if (!is_file($filePath) || !is_writable($filePath)) return false;
$raw = @file_get_contents($filePath);
if (!is_string($raw) || trim($raw) === '') return false;
try {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
$dateIso = $eventData['date'] ?? '';
$allDay = !empty($eventData['allDay']);
if ($scope === 'future' && $recurrenceId !== '') {
// "This and future": truncate master RRULE, then create a new standalone event
$edited = self::editFutureOccurrences($calendar, $uid, $dateIso, $eventData);
if (!$edited) return false;
} elseif ($scope === 'all') {
// "All occurrences": find and edit the master event directly
$master = self::findMasterByUid($calendar, $uid);
if ($master === null) return false;
self::applyEventData($master, $eventData);
} else {
// "This occurrence" (or non-recurring): find/create occurrence override
$target = null;
// Find matching component
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
$target = $component;
break;
}
}
// For recurring events without an existing override, create one
if ($target === null && $recurrenceId !== '') {
$target = self::createEditOccurrenceOverride($calendar, $uid, $recurrenceId, $dateIso, $eventData);
}
if ($target === null) return false;
self::applyEventData($target, $eventData);
}
$output = $calendar->serialize();
return self::atomicWrite($filePath, $output);
} catch (Throwable $e) {
return false;
}
}
/**
* Find the master VEVENT (the one with RRULE or without RECURRENCE-ID) by UID.
*/
private static function findMasterByUid(VCalendar $calendar, string $uid): ?VEvent
{
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
// Master = has no RECURRENCE-ID
if (!isset($component->{'RECURRENCE-ID'})) {
return $component;
}
}
return null;
}
/**
* Create an occurrence override VEVENT for a recurring event.
*/
private static function createEditOccurrenceOverride(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso, array $eventData): ?VEvent
{
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
if (!isset($component->RRULE)) continue;
$overrideProps = [
'UID' => $uid,
'SUMMARY' => $eventData['summary'] ?? '',
];
$isAllDayMaster = strtoupper((string)($component->DTSTART['VALUE'] ?? '')) === 'DATE';
if ($isAllDayMaster) {
$recurrenceValue = str_replace('-', '', $dateIso);
$overrideProps['RECURRENCE-ID'] = $recurrenceValue;
} else {
$masterStart = $component->DTSTART->getDateTime();
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue);
} else {
$overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue) . 'Z';
}
}
$target = $calendar->add('VEVENT', $overrideProps);
if ($isAllDayMaster) {
$target->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
$masterStart = $component->DTSTART->getDateTime();
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$target->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
}
}
return $target;
}
return null;
}
/**
* Apply event data to a VEVENT component (shared by all edit scopes).
*/
private static function applyEventData(VEvent $target, array $eventData): void
{
$dateIso = $eventData['date'] ?? '';
$allDay = !empty($eventData['allDay']);
$target->SUMMARY = $eventData['summary'] ?? '';
if ($allDay) {
$target->DTSTART = str_replace('-', '', $dateIso);
$target->DTSTART['VALUE'] = 'DATE';
unset($target->DTEND);
} else {
$startTime = $eventData['startTime'] ?? '00:00';
$endTime = $eventData['endTime'] ?? '';
$target->DTSTART = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00';
if ($endTime !== '') {
if (isset($target->DTEND)) {
$target->DTEND = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00';
} else {
$target->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00');
}
}
}
if (!empty($eventData['location'])) {
$target->LOCATION = $eventData['location'];
} else {
unset($target->LOCATION);
}
if (!empty($eventData['description'])) {
$target->DESCRIPTION = $eventData['description'];
} else {
unset($target->DESCRIPTION);
}
}
/**
* Handle "this and future" edits: truncate master RRULE before dateIso,
* then create a new standalone event with the edited data.
*/
private static function editFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso, array $eventData): bool
{
$master = self::findMasterByUid($calendar, $uid);
if ($master === null) return false;
// Truncate master RRULE to end before this date
self::deleteFutureOccurrences($calendar, $uid, $dateIso);
// Create a new standalone event with the edited data and a new UID
$newProps = [
'UID' => self::generateUid(),
'SUMMARY' => $eventData['summary'] ?? '',
'DTSTAMP' => gmdate('Ymd\THis\Z'),
];
$newEvent = $calendar->add('VEVENT', $newProps);
self::applyEventData($newEvent, $eventData);
return true;
}
/**
* Delete an event from a local ICS file.
*
* @param string $filePath
* @param string $uid
* @param string $recurrenceId
* @param string $dateIso
* @param string $scope 'all', 'this', or 'future'
* @return bool
*/
public static function deleteEvent(string $filePath, string $uid, string $recurrenceId, string $dateIso, string $scope = 'all'): bool
{
if ($filePath === '' || $uid === '') return false;
if (!is_file($filePath) || !is_writable($filePath)) return false;
$raw = @file_get_contents($filePath);
if (!is_string($raw) || trim($raw) === '') return false;
try {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
if ($scope === 'all') {
// Remove all components with this UID
self::removeComponentsByUid($calendar, $uid);
} elseif ($scope === 'this') {
// For a single occurrence, add EXDATE to master or remove the override
self::deleteOccurrence($calendar, $uid, $recurrenceId, $dateIso);
} elseif ($scope === 'future') {
// Modify RRULE UNTIL on the master
self::deleteFutureOccurrences($calendar, $uid, $dateIso);
} else {
return false;
}
$output = $calendar->serialize();
return self::atomicWrite($filePath, $output);
} catch (Throwable $e) {
return false;
}
}
/**
* Remove all VEVENT components with a given UID.
*
* @param VCalendar $calendar
* @param string $uid
*/
protected static function removeComponentsByUid(VCalendar $calendar, string $uid): void
{
$toRemove = [];
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (trim((string)($component->UID ?? '')) === $uid) {
$toRemove[] = $component;
}
}
foreach ($toRemove as $component) {
$calendar->remove($component);
}
}
/**
* Delete a single occurrence of a recurring event.
*
* If the occurrence has an override component, remove it and add EXDATE.
* If not, just add EXDATE to the master.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $dateIso
*/
protected static function deleteOccurrence(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso): void
{
// Remove any existing override for this occurrence
$toRemove = [];
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (trim((string)($component->UID ?? '')) !== $uid) continue;
if (!isset($component->{'RECURRENCE-ID'})) continue;
$rid = $component->{'RECURRENCE-ID'}->getDateTime();
if ($rid !== null && $rid->format('Y-m-d') === $dateIso) {
$toRemove[] = $component;
}
}
foreach ($toRemove as $component) {
$calendar->remove($component);
}
// Add EXDATE to the master
$master = null;
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (trim((string)($component->UID ?? '')) !== $uid) continue;
if (isset($component->{'RECURRENCE-ID'})) continue;
$master = $component;
break;
}
if ($master !== null) {
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
if ($isAllDay) {
$exdateValue = str_replace('-', '', $dateIso);
$exdate = $master->add('EXDATE', $exdateValue);
$exdate['VALUE'] = 'DATE';
} else {
$masterStart = $master->DTSTART->getDateTime();
$exdateValue = str_replace('-', '', $dateIso) . 'T' . $masterStart->format('His');
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$exdate = $master->add('EXDATE', $exdateValue);
$exdate['TZID'] = $tz->getName();
} else {
$master->add('EXDATE', $exdateValue . 'Z');
}
}
}
}
/**
* Delete this and all future occurrences by setting UNTIL on the master RRULE.
*
* Also removes any override components on or after the given date.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $dateIso
*/
protected static function deleteFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso): void
{
// Remove overrides on or after dateIso
$toRemove = [];
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (trim((string)($component->UID ?? '')) !== $uid) continue;
if (!isset($component->{'RECURRENCE-ID'})) continue;
$rid = $component->{'RECURRENCE-ID'}->getDateTime();
if ($rid !== null && $rid->format('Y-m-d') >= $dateIso) {
$toRemove[] = $component;
}
}
foreach ($toRemove as $component) {
$calendar->remove($component);
}
// Set UNTIL on the master RRULE to the day before
$master = null;
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (trim((string)($component->UID ?? '')) !== $uid) continue;
if (isset($component->{'RECURRENCE-ID'})) continue;
$master = $component;
break;
}
if ($master !== null && isset($master->RRULE)) {
$rrule = (string)$master->RRULE;
// Remove existing UNTIL or COUNT
$rrule = preg_replace('/;?(UNTIL|COUNT)=[^;]*/i', '', $rrule);
// Calculate the day before
try {
$until = new \DateTimeImmutable($dateIso . ' 00:00:00', new \DateTimeZone('UTC'));
$until = $until->sub(new \DateInterval('P1D'));
$rrule .= ';UNTIL=' . $until->format('Ymd') . 'T235959Z';
} catch (Throwable $e) {
return;
}
$master->RRULE = $rrule;
}
}
/**
* Generate a unique UID for a new calendar event.
*
* @return string
*/
protected static function generateUid(): string
{
return sprintf(
'%s-%s@luxtools',
gmdate('Ymd-His'),
bin2hex(random_bytes(8))
);
}
/** /**
* Atomic file write using a temp file and rename. * Atomic file write using a temp file and rename.
* *

393
style.css
View File

@@ -1,3 +1,5 @@
/* Dialog infrastructure styles are in dialog.css, loaded via CSS_STYLES_INCLUDED hook in action.php */
/* luxtools plugin styles /* luxtools plugin styles
* Keep this minimal and scoped to the plugin container. * Keep this minimal and scoped to the plugin container.
*/ */
@@ -7,7 +9,6 @@ div.luxtools-plugin table thead tr:hover > * {
background-color: @ini_background_alt !important; background-color: @ini_background_alt !important;
} }
/* "Open Location" row above the header should be visually smaller. */ /* "Open Location" row above the header should be visually smaller. */
div.luxtools-plugin table thead tr.luxtools-openlocation-row td { div.luxtools-plugin table thead tr.luxtools-openlocation-row td {
font-size: 80%; font-size: 80%;
@@ -229,8 +230,12 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block > br {
} }
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit, div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="text"].edit, div.plugin_luxtools_admin
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit, form.plugin_luxtools_admin_form
input[type="text"].edit,
div.plugin_luxtools_admin
form.plugin_luxtools_admin_form
input[type="number"].edit,
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select { div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
flex: 1 1 auto; flex: 1 1 auto;
margin-left: auto; margin-left: auto;
@@ -249,7 +254,10 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
} }
/* Checkbox controls: keep them in the control column, left-aligned. */ /* Checkbox controls: keep them in the control column, left-aligned. */
div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block input[type="checkbox"] { div.plugin_luxtools_admin
form.plugin_luxtools_admin_form
label.block
input[type="checkbox"] {
margin-left: 0; margin-left: 0;
align-self: center; align-self: center;
} }
@@ -265,8 +273,12 @@ div.plugin_luxtools_admin form.plugin_luxtools_admin_form label.block input[type
} }
div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit, div.plugin_luxtools_admin form.plugin_luxtools_admin_form textarea.edit,
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="text"].edit, div.plugin_luxtools_admin
div.plugin_luxtools_admin form.plugin_luxtools_admin_form input[type="number"].edit, form.plugin_luxtools_admin_form
input[type="text"].edit,
div.plugin_luxtools_admin
form.plugin_luxtools_admin_form
input[type="number"].edit,
div.plugin_luxtools_admin form.plugin_luxtools_admin_form select { div.plugin_luxtools_admin form.plugin_luxtools_admin_form select {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -386,12 +398,12 @@ html.luxtools-noscroll body {
} }
.luxtools-lightbox button.luxtools-lightbox-zone-prev::after { .luxtools-lightbox button.luxtools-lightbox-zone-prev::after {
content: ''; content: "";
left: 0.35em; left: 0.35em;
} }
.luxtools-lightbox button.luxtools-lightbox-zone-next::after { .luxtools-lightbox button.luxtools-lightbox-zone-next::after {
content: ''; content: "";
right: 0.35em; right: 0.35em;
} }
@@ -416,7 +428,7 @@ html.luxtools-noscroll body {
.luxtools-lightbox button.luxtools-lightbox-close:hover, .luxtools-lightbox button.luxtools-lightbox-close:hover,
.luxtools-lightbox button.luxtools-lightbox-close:focus-visible { .luxtools-lightbox button.luxtools-lightbox-close:focus-visible {
background: rgba(0, 0, 0, 0.60); background: rgba(0, 0, 0, 0.6);
border-radius: 999px; border-radius: 999px;
} }
@@ -432,7 +444,10 @@ html.luxtools-noscroll body {
.luxtools-grouping { .luxtools-grouping {
display: grid; display: grid;
grid-template-columns: repeat(var(--luxtools-grouping-cols, 2), minmax(0, 1fr)); grid-template-columns: repeat(
var(--luxtools-grouping-cols, 2),
minmax(0, 1fr)
);
gap: var(--luxtools-grouping-gap, 0); gap: var(--luxtools-grouping-gap, 0);
justify-content: var(--luxtools-grouping-justify, start); justify-content: var(--luxtools-grouping-justify, start);
align-items: var(--luxtools-grouping-align, start); align-items: var(--luxtools-grouping-align, start);
@@ -604,7 +619,10 @@ div.luxtools-calendar td.luxtools-calendar-day-today {
} }
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a, div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a { div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -617,33 +635,77 @@ div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > sp
padding: 0.1em 0; padding: 0.1em 0;
} }
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:link, div.luxtools-calendar.luxtools-calendar-size-small
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a.wikilink2:visited, td.luxtools-calendar-day
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:link, > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a.wikilink2:visited { div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a.wikilink2:visited {
color: @ini_missing; color: @ini_missing;
border-bottom: 0; border-bottom: 0;
} }
div.luxtools-calendar.luxtools-calendar-size-small
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:hover, td.luxtools-calendar-day
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:focus, > a:hover,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:active, div.luxtools-calendar.luxtools-calendar-size-small
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > a:visited, td.luxtools-calendar-day
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover, > a:focus,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus, div.luxtools-calendar.luxtools-calendar-size-small
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active, td.luxtools-calendar-day
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited { > a:active,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> a:visited,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:hover,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:focus,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:active,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:visited {
text-decoration: none; text-decoration: none;
border-bottom: 0; border-bottom: 0;
box-shadow: none; box-shadow: none;
} }
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a, div.luxtools-calendar.luxtools-calendar-size-small
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:visited, td.luxtools-calendar-day
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:hover, > span.curid
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:focus, > a,
div.luxtools-calendar.luxtools-calendar-size-small td.luxtools-calendar-day > span.curid > a:active { div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:visited,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:hover,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:focus,
div.luxtools-calendar.luxtools-calendar-size-small
td.luxtools-calendar-day
> span.curid
> a:active {
font-weight: bold; font-weight: bold;
text-decoration: underline; text-decoration: underline;
border-bottom: 0; border-bottom: 0;
@@ -654,21 +716,24 @@ div.luxtools-calendar td.luxtools-calendar-day:hover {
background-color: @ini_background_alt; background-color: @ini_background_alt;
} }
div.luxtools-calendar td.luxtools-calendar-day.luxtools-calendar-day-today:hover { div.luxtools-calendar
td.luxtools-calendar-day.luxtools-calendar-day-today:hover {
background-color: @ini_highlight; background-color: @ini_highlight;
} }
/* ============================================================ /* ============================================================
* Calendar Widget Indicators * Calendar Widget Indicators
* Colored corner markers showing which slots have events on a day. * Colored corner markers showing which slots have events on a day.
* Positions: general=top-left, maintenance=top-right, * Positions: general=top-left, slot2=top-right,
* slot3=bottom-right, slot4=bottom-left (clockwise) * slot3=bottom-right, slot4=bottom-left (clockwise)
* ============================================================ */ * ============================================================ */
div.luxtools-calendar td.luxtools-calendar-day { div.luxtools-calendar td.luxtools-calendar-day {
position: relative; position: relative;
} }
div.luxtools-calendar.luxtools-calendar-size-large table.luxtools-calendar-table td { div.luxtools-calendar.luxtools-calendar-size-large
table.luxtools-calendar-table
td {
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
@@ -677,25 +742,36 @@ div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day {
height: 8.25em; height: 8.25em;
} }
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty { div.luxtools-calendar.luxtools-calendar-size-large
td.luxtools-calendar-day-empty {
height: 8.25em; height: 8.25em;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-frame {
min-height: 8.25em; min-height: 8.25em;
padding: 0.35em 0.4em 0.4em 0.4em; padding: 0.35em 0.4em 0.4em 0.4em;
box-sizing: border-box; box-sizing: border-box;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number {
text-align: right; text-align: right;
margin-bottom: 0.25em; margin-bottom: 0.25em;
line-height: 1.1; line-height: 1.1;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a, div.luxtools-calendar.luxtools-calendar-size-large
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > span.curid > a, .luxtools-calendar-day-number
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a { > a,
div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number
> span.curid
> a,
div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number
span.curid
> a {
display: inline; display: inline;
min-height: 0; min-height: 0;
padding: 0; padding: 0;
@@ -706,18 +782,30 @@ div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number
font-weight: bold; font-weight: bold;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:link, div.luxtools-calendar.luxtools-calendar-size-large
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number > a.wikilink2:visited, .luxtools-calendar-day-number
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:link, > a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-number span.curid > a.wikilink2:visited { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number
> a.wikilink2:visited,
div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number
span.curid
> a.wikilink2:link,
div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-number
span.curid
> a.wikilink2:visited {
color: @ini_missing; color: @ini_missing;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-events { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-events {
overflow: hidden; overflow: hidden;
} }
div.luxtools-calendar.luxtools-calendar-size-large ul.luxtools-calendar-event-list { div.luxtools-calendar.luxtools-calendar-size-large
ul.luxtools-calendar-event-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -735,17 +823,20 @@ div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event {
cursor: pointer; cursor: pointer;
} }
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event:hover { div.luxtools-calendar.luxtools-calendar-size-large
li.luxtools-calendar-event:hover {
background-color: @ini_highlight; background-color: @ini_highlight;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-time { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-event-time {
flex: 0 0 auto; flex: 0 0 auto;
font-weight: bold; font-weight: bold;
white-space: nowrap; white-space: nowrap;
} }
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-title { div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-event-title {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
white-space: nowrap; white-space: nowrap;
@@ -753,7 +844,8 @@ div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-event-titl
text-overflow: ellipsis; text-overflow: ellipsis;
} }
div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event-more { div.luxtools-calendar.luxtools-calendar-size-large
li.luxtools-calendar-event-more {
border-left-color: @ini_border; border-left-color: @ini_border;
justify-content: flex-end; justify-content: flex-end;
font-style: italic; font-style: italic;
@@ -761,8 +853,10 @@ div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event-mo
@media (max-width: 800px) { @media (max-width: 800px) {
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day, div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day,
div.luxtools-calendar.luxtools-calendar-size-large td.luxtools-calendar-day-empty, div.luxtools-calendar.luxtools-calendar-size-large
div.luxtools-calendar.luxtools-calendar-size-large .luxtools-calendar-day-frame { td.luxtools-calendar-day-empty,
div.luxtools-calendar.luxtools-calendar-size-large
.luxtools-calendar-day-frame {
height: 7em; height: 7em;
min-height: 7em; min-height: 7em;
} }
@@ -807,7 +901,6 @@ div.luxtools-calendar.luxtools-calendar-size-large li.luxtools-calendar-event-mo
clip-path: polygon(0 0, 0 100%, 100% 100%); clip-path: polygon(0 0, 0 100%, 100% 100%);
} }
/* ============================================================ /* ============================================================
* Chronological Events on Day Pages * Chronological Events on Day Pages
* ============================================================ */ * ============================================================ */
@@ -833,135 +926,10 @@ div.luxtools-chronological-events li[data-luxtools-event] .luxtools-event-time {
margin-right: 0.25em; margin-right: 0.25em;
} }
/* ============================================================ /* ============================================================
* Maintenance Tasks * Event Popup (content-specific styles structural dialog
* styles live in dialog.css)
* ============================================================ */ * ============================================================ */
div.luxtools-chronological-maintenance li {
border-left-color: #e67e22;
}
li.luxtools-maintenance-task.luxtools-task-completed {
opacity: 0.5;
text-decoration: line-through;
}
button.luxtools-task-action,
button.luxtools-task-complete-btn {
margin-left: 0.5em;
padding: 0.15em 0.5em;
font-size: 0.85em;
border: 1px solid @ini_border;
border-radius: 0.2em;
background-color: @ini_background_alt;
cursor: pointer;
}
button.luxtools-task-action:hover,
button.luxtools-task-complete-btn:hover {
background-color: @ini_highlight;
}
button.luxtools-task-action:disabled,
button.luxtools-task-complete-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/* ============================================================
* Maintenance Task List (syntax plugin)
* ============================================================ */
div.luxtools-maintenance-tasks {
margin: 1em 0;
}
ul.luxtools-maintenance-task-list {
list-style: none;
padding-left: 0;
}
ul.luxtools-maintenance-task-list li {
padding: 0.35em 0.5em;
margin: 0.25em 0;
border-left: 3px solid #e67e22;
}
li.luxtools-task-overdue .luxtools-task-date {
color: #c0392b;
font-weight: bold;
}
.luxtools-task-date {
font-family: monospace;
margin-right: 0.5em;
}
.luxtools-task-time {
font-weight: bold;
margin-right: 0.25em;
}
.luxtools-maintenance-task-item.luxtools-task-completed {
opacity: 0.5;
text-decoration: line-through;
}
/* ============================================================
* Event Popup
* ============================================================ */
.luxtools-event-popup-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
z-index: 10000;
justify-content: center;
align-items: center;
}
.luxtools-event-popup {
background: @ini_background;
border: 1px solid @ini_border;
border-radius: 0.4em;
padding: 1.5em;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.luxtools-event-popup-close {
position: absolute;
top: 0.5em;
right: 0.75em;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: @ini_text;
line-height: 1;
}
.luxtools-event-popup-close:hover {
opacity: 0.7;
}
.luxtools-event-popup-title {
margin: 0 0 0.75em 0;
padding-right: 1.5em;
}
.luxtools-event-popup-field {
margin: 0.5em 0;
}
.luxtools-event-popup-description { .luxtools-event-popup-description {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -973,6 +941,73 @@ li.luxtools-task-overdue .luxtools-task-date {
font-size: 0.9em; font-size: 0.9em;
} }
.luxtools-recurrence-actions {
flex-direction: column;
}
/* Day popup */
.luxtools-day-popup-events {
list-style: none;
margin: 0;
padding: 0;
}
.luxtools-day-popup-event-item {
padding: 0.3em 0.4em;
margin: 0.2em 0;
border-radius: 0.2em;
cursor: pointer;
}
.luxtools-day-popup-event-item:hover {
background: rgba(0, 0, 0, 0.05);
}
.luxtools-day-popup-empty {
opacity: 0.6;
font-style: italic;
}
/* Event form */
.luxtools-event-form .luxtools-event-form-field {
margin: 0.5em 0;
}
.luxtools-event-form .luxtools-event-form-field label {
display: block;
}
.luxtools-event-form .luxtools-event-form-field input[type="text"],
.luxtools-event-form .luxtools-event-form-field input[type="date"],
.luxtools-event-form .luxtools-event-form-field input[type="time"],
.luxtools-event-form .luxtools-event-form-field textarea,
.luxtools-event-form .luxtools-event-form-field select {
width: 100%;
box-sizing: border-box;
}
.luxtools-event-form-time-fields {
display: flex;
gap: 0.75em;
}
.luxtools-event-form-time-fields .luxtools-event-form-field {
flex: 1;
}
/* Calendar sync widget (syntax) */
.luxtools-calendar-sync-widget {
margin: 0.5em 0;
}
.luxtools-calendar-sync-status {
margin-left: 0.75em;
}
/* Clickable day cells */
td.luxtools-calendar-day[data-luxtools-day] {
cursor: pointer;
}
/* ============================================================ /* ============================================================
* Notifications (fallback) * Notifications (fallback)

View File

@@ -91,6 +91,16 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
$size = ChronologicalCalendarWidget::normalizeSize((string)($data['size'] ?? 'large')); $size = ChronologicalCalendarWidget::normalizeSize((string)($data['size'] ?? 'large'));
$showTimes = (bool)($data['show_times'] ?? true); $showTimes = (bool)($data['show_times'] ?? true);
if ($size === 'small') {
$resolved = $this->resolveMonthFromCookie(
'luxtools_calendar_month_' . preg_replace('/[^a-zA-Z0-9]/', '_', $baseNs)
) ?? $this->resolveMonthFromCookie('luxtools_client_month');
if ($resolved !== null) {
$year = $resolved['year'];
$month = $resolved['month'];
}
}
$slots = CalendarSlot::loadEnabled($this); $slots = CalendarSlot::loadEnabled($this);
$widgetSlots = CalendarSlot::filterWidgetVisible($slots); $widgetSlots = CalendarSlot::filterWidgetVisible($slots);
$indicators = []; $indicators = [];
@@ -122,6 +132,25 @@ class syntax_plugin_luxtools_calendar extends SyntaxPlugin
return true; return true;
} }
/**
* @param string $cookieName
* @return array{year:int,month:int}|null
*/
protected function resolveMonthFromCookie(string $cookieName): ?array
{
$raw = $_COOKIE[$cookieName] ?? null;
if ($raw === null) return null;
$decoded = json_decode($raw, true);
if (!is_array($decoded)) return null;
$year = (int)($decoded['year'] ?? 0);
$month = (int)($decoded['month'] ?? 0);
if ($year < 1 || $month < 1 || $month > 12) return null;
return ['year' => $year, 'month' => $month];
}
/** /**
* @param string $flags * @param string $flags
* @return array<string,string> * @return array<string,string>

79
syntax/calendarsync.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Calendar sync button syntax.
*
* Renders a manual calendar sync button on any wiki page.
* Only visible to admins. The actual sync is handled by the
* existing AJAX endpoint (luxtools_calendar_sync).
*
* Syntax:
* - {{calendar_sync>}}
*/
class syntax_plugin_luxtools_calendarsync extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 225;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern('\{\{calendar_sync>\}\}', $mode, 'plugin_luxtools_calendarsync');
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
return [];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if ($data === false || !is_array($data)) return false;
if ($format !== 'xhtml') return false;
if (!($renderer instanceof Doku_Renderer_xhtml)) return false;
$renderer->nocache();
// Only render for authenticated users
if (empty($_SERVER['REMOTE_USER'])) {
return true;
}
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$sectok = function_exists('getSecurityToken') ? getSecurityToken() : '';
$buttonLabel = (string)$this->getLang('calendar_sync_button');
if ($buttonLabel === '') $buttonLabel = 'Sync Calendars';
$renderer->doc .= '<div class="luxtools-plugin luxtools-calendar-sync-widget">';
$renderer->doc .= '<button type="button" class="button luxtools-calendar-sync-btn"'
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
. ' data-luxtools-sectok="' . hsc($sectok) . '"'
. '>' . hsc($buttonLabel) . '</button>';
$renderer->doc .= '<span class="luxtools-calendar-sync-status"></span>';
$renderer->doc .= '</div>';
return true;
}
}

View File

@@ -1,194 +0,0 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\CalendarService;
use dokuwiki\plugin\luxtools\CalendarSlot;
require_once(__DIR__ . '/../autoload.php');
/**
* luxtools Plugin: Maintenance task list syntax.
*
* Renders a list of all non-completed maintenance tasks due today or earlier.
*
* Syntax:
* {{maintenance_tasks>}}
* {{maintenance_tasks>&past=14}}
*/
class syntax_plugin_luxtools_maintenance extends SyntaxPlugin
{
private const DEFAULT_PAST_DAYS = 30;
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 225;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern(
'\{\{maintenance_tasks>.*?\}\}',
$mode,
'plugin_luxtools_maintenance'
);
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
$match = substr($match, strlen('{{maintenance_tasks>'), -2);
$params = $this->parseFlags($match);
return [
'ok' => true,
'past' => $this->normalizePastDays($params['past'] ?? null),
];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
if ($data === false || !is_array($data)) return false;
if ($format !== 'xhtml') return false;
if (!($renderer instanceof Doku_Renderer_xhtml)) return false;
$renderer->nocache();
$slots = CalendarSlot::loadAll($this);
$maintenanceSlot = $slots['maintenance'] ?? null;
if ($maintenanceSlot === null || !$maintenanceSlot->isEnabled()) {
$renderer->doc .= '<div class="luxtools-plugin luxtools-maintenance-tasks">'
. '<p class="luxtools-empty">'
. hsc($this->getLang('maintenance_no_tasks'))
. '</p></div>';
return true;
}
$todayIso = date('Y-m-d');
$pastDays = $this->normalizePastDays($data['past'] ?? null);
$tasks = CalendarService::openMaintenanceTasks($maintenanceSlot, $todayIso, $pastDays);
$ajaxUrl = defined('DOKU_BASE') ? (string)DOKU_BASE . 'lib/exe/ajax.php' : 'lib/exe/ajax.php';
$secToken = function_exists('getSecurityToken') ? (string)getSecurityToken() : '';
$title = (string)$this->getLang('chronological_maintenance_title');
if ($title === '') $title = 'Tasks';
$renderer->doc .= '<div class="luxtools-plugin luxtools-maintenance-tasks"'
. ' data-luxtools-ajax-url="' . hsc($ajaxUrl) . '"'
. ' data-luxtools-sectok="' . hsc($secToken) . '">';
$renderer->doc .= '<h3>' . hsc($title) . '</h3>';
if ($tasks === []) {
$noTasks = (string)$this->getLang('maintenance_no_tasks');
if ($noTasks === '') $noTasks = 'No open tasks.';
$renderer->doc .= '<p class="luxtools-empty">' . hsc($noTasks) . '</p>';
} else {
$renderer->doc .= '<ul class="luxtools-maintenance-task-list">';
foreach ($tasks as $task) {
$overdue = ($task->dateIso < $todayIso);
$classes = 'luxtools-maintenance-task-item';
if ($overdue) {
$classes .= ' luxtools-task-overdue';
}
$renderer->doc .= '<li class="' . $classes . '"';
$renderer->doc .= ' data-uid="' . hsc($task->uid) . '"';
$renderer->doc .= ' data-date="' . hsc($task->dateIso) . '"';
$renderer->doc .= ' data-recurrence="' . hsc($task->recurrenceId) . '"';
$renderer->doc .= '>';
// Date badge
$renderer->doc .= '<span class="luxtools-task-date">' . hsc($task->dateIso) . '</span> ';
// Time if not all-day
if ($task->time !== '') {
$renderer->doc .= '<span class="luxtools-task-time">' . hsc($task->time) . '</span> ';
}
// Summary
$renderer->doc .= '<span class="luxtools-task-summary">' . hsc($task->summary) . '</span>';
// Complete button
$completeLabel = (string)$this->getLang('maintenance_task_complete');
if ($completeLabel === '') $completeLabel = 'Complete';
$renderer->doc .= ' <button class="luxtools-task-complete-btn" type="button"'
. ' data-action="complete"'
. '>' . hsc($completeLabel) . '</button>';
$renderer->doc .= '</li>';
}
$renderer->doc .= '</ul>';
}
$renderer->doc .= '</div>';
return true;
}
/**
* @param string $rawFlags
* @return array<string,string>
*/
protected function parseFlags(string $rawFlags): array
{
$rawFlags = trim($rawFlags);
if ($rawFlags === '') {
return [];
}
if ($rawFlags[0] === '&') {
$rawFlags = substr($rawFlags, 1);
}
$params = [];
foreach (explode('&', $rawFlags) as $flag) {
if (trim($flag) === '') continue;
[$name, $value] = array_pad(explode('=', $flag, 2), 2, '');
$name = strtolower(trim($name));
$value = trim($value);
if ($name === '') continue;
$params[$name] = $value;
}
return $params;
}
/**
* @param mixed $value
* @return int
*/
protected function normalizePastDays($value): int
{
if ($value === null || $value === '') {
return self::DEFAULT_PAST_DAYS;
}
if (is_int($value)) {
return max(0, $value);
}
$value = trim((string)$value);
if ($value === '' || !preg_match('/^-?\d+$/', $value)) {
return self::DEFAULT_PAST_DAYS;
}
return max(0, (int)$value);
}
}

60
syntax/moviemarker.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
use dokuwiki\Extension\SyntaxPlugin;
/**
* luxtools Plugin: Movie marker syntax.
*
* Matches the <!-- BEGIN MOVIE --> and <!-- END MOVIE --> markers inserted
* by the movie-import toolbar button and renders them as invisible output.
* The markers remain in the page source for reliable find-and-replace on
* repeated imports.
*/
class syntax_plugin_luxtools_moviemarker extends SyntaxPlugin
{
/** @inheritdoc */
public function getType()
{
return 'substition';
}
/** @inheritdoc */
public function getPType()
{
return 'block';
}
/** @inheritdoc */
public function getSort()
{
return 319;
}
/** @inheritdoc */
public function connectTo($mode)
{
$this->Lexer->addSpecialPattern(
'<!-- BEGIN MOVIE -->',
$mode,
'plugin_luxtools_moviemarker'
);
$this->Lexer->addSpecialPattern(
'<!-- END MOVIE -->',
$mode,
'plugin_luxtools_moviemarker'
);
}
/** @inheritdoc */
public function handle($match, $state, $pos, Doku_Handler $handler)
{
return [];
}
/** @inheritdoc */
public function render($format, Doku_Renderer $renderer, $data)
{
// Render nothing — markers are source-level only.
return true;
}
}

177
task.prompt.md Normal file
View File

@@ -0,0 +1,177 @@
# Calendar Improvements
## Goal
Improve the calendar feature so that calendar sync is easier to trigger, day and event popups are more useful, and calendar events can be created, edited, and deleted from the UI.
This prompt is intended for an implementation agent. Before changing code, inspect the current implementation and keep changes aligned with existing Dokuwiki and plugin conventions.
## Relevant Code Areas
- `admin/main.php`: admin page that already exposes a manual calendar sync button
- `action.php`: currently contains calendar AJAX handlers, including manual sync
- `js/event-popup.js`: current popup behavior for calendar events
- `syntax/calendar.php`: existing calendar syntax implementation and parsing style
- `src/ChronologicalCalendarWidget.php`: server-rendered calendar widget HTML and event/day markup
- `README.md`: should be updated if user-facing syntax or behavior changes
## Current State
- Manual calendar sync already exists, but only through the admin page.
- Event popups currently open when clicking an event element, not when clicking empty space in a day cell.
- Event popup currently shows the date/time block.
- Calendar sync is currently handled in `action.php`.
- Sync is currently one-directional: remote CalDAV -> local `.ics` file.
- The README already notes that automatic sync is not implemented and would likely require cron.
## Requested Work
### 1. Add a Manual Sync Syntax
Add a new wiki syntax that renders a manual sync control on normal pages, so sync is not limited to the admin page.
Requirements:
- Reuse the existing sync behavior instead of duplicating logic.
- Keep permission checks strict. The current sync endpoint is admin-only; preserve that unless a strong reason emerges to change it.
- Follow the existing syntax plugin style used in `syntax/calendar.php`.
- Update `README.md` with syntax usage and any permission limitations.
Preferred implementation direction:
- Move sync logic out of `action.php` into a dedicated class/service if that simplifies reuse and maintainability.
- The syntax should only provide UI/rendering; the actual sync work should remain centralized.
Clarification needed:
- The exact syntax name and parameters are not specified. If no better convention exists in the codebase, choose a minimal, explicit syntax and document it.
-> Use the syntax name `calendar_sync` with no parameters.
### 2. Improve Event Popups
Update the event popup behavior in `js/event-popup.js` and related server-rendered markup.
Requirements:
- Do not show the date in the popup when the popup is opened from a specific day context.
- Clicking empty space inside a calendar day cell should open a popup for that day.
- That day-level popup should list all events for the clicked day.
- Preserve support for clicking an individual event to inspect that specific event.
Implementation notes:
- Inspect how day cells and event items are rendered in `src/ChronologicalCalendarWidget.php` and related output in `action.php`.
- Prefer attaching structured data to the rendered day cell rather than scraping text from the DOM.
- Keep the popup accessible: overlay close, escape key, and explicit close button should continue to work.
Clarification needed:
- The desired behavior when a day has no events is not specified. Reasonable default: show a popup with a short "no events" message plus the create action if event creation is implemented.
-> yes, show a "no events" message with the create action if implemented.
### 3. Add Event Creation
Add a `Create Event` action to the day popup.
Requirements:
- The action should create a new calendar event for the selected day.
- After successful creation, the UI should reflect the change without requiring the user to manually refresh unrelated state.
- Keep the implementation maintainable and explicit.
Clarification needed:
- The request does not specify the input UI for creation. The agent should either:
- implement a simple popup form, or
- stop and ask for clarification if the appropriate form fields are unclear after inspecting the data model.
- The target calendar slot for newly created events is not specified.
- It is not specified whether creation should be admin-only, authenticated-user-only, or available to all viewers.
- It is not specified whether newly created events must be written only to the local `.ics` file or also to remote CalDAV immediately.
-> Implement a simple input ui as a popup with the same styling as the event popup, with fields for summary, date/time, location, and description. Show a dropdown to select the calendar slot, preselecting the first one. Restrict event creation to authenticated users. Write newly created events to the local `.ics` file only, after that perform the event creation via CalDAV if implemented to prevent a full re-sync.
### 4. Add Event Editing
Add an `Edit` action for existing events.
Requirements:
- The action should allow modification of an existing event from the popup.
- The correct event instance must be edited, including enough identifier data to distinguish recurring occurrences if necessary.
- After successful editing, refresh the affected day/event UI.
Clarification needed:
- The editable field set is not defined. At minimum, summary, date/time, location, and description appear relevant, but confirm against the current event model before implementing.
- Recurring event editing semantics are not defined: edit one occurrence vs. entire series.
- Remote write-back expectations are not defined.
-> re-use the same input UI as creation, but pre-populate fields with the existing event data.
When trying to save a recurring event, ask the user if they want to edit just the selected occurrence or the entire series, or all future occurrences. The changes should be written to the local `.ics` file first, then if CalDAV write-back is implemented, perform the edit via CalDAV to prevent a full re-sync.
### 5. Add Event Deletion
Add a `Delete` action for existing events.
Requirements:
- Deletion should be available from the event popup.
- Use an explicit confirmation step to avoid accidental deletion.
- After deletion, update the UI and related calendar data.
Clarification needed:
- Recurring event deletion semantics are not defined: delete one occurrence vs. entire series.
- Remote write-back expectations are not defined.
-> When trying to delete a recurring event, ask the user if they want to delete just the selected occurrence or the entire series, or all future occurrences. Perform deletion on the local `.ics` file first, then if CalDAV write-back is implemented, perform the deletion via CalDAV to prevent a full re-sync.
### 6. Automatic Sync
Do not treat this as a mandatory coding task unless the implementation path is already obvious and safe.
Required output:
- Provide a short implementation proposal for automatic sync.
- Compare at least these approaches:
- cron invoking a Dokuwiki entry point or plugin-specific script
- cron calling the existing AJAX endpoint
- Recommend the most maintainable option for a long-lived personal Dokuwiki plugin.
Important context:
- The current README already suggests cron as the likely approach.
- A direct web or AJAX trigger from cron may work, but a CLI-oriented entry point may be more robust and easier to secure.
### 7. Direct Sync of Changed Events
When an event is created, edited, or deleted, the calendar should update promptly so the UI and stored data stay in sync.
Requirements:
- The local calendar data and rendered calendar state should reflect the change immediately after a successful mutation.
- If remote CalDAV write-back is implemented for create, edit, or delete, trigger it as part of the mutation flow rather than relying on a later full sync.
- Avoid introducing a design where a later full remote -> local sync silently overwrites recent local edits without warning.
Clarification needed:
- The current system only guarantees remote -> local full sync, with limited immediate remote write-back behavior for maintenance task completion. Create, edit, and delete may require a broader sync design decision before implementation.
-> Implement immediate local updates to the `.ics` file and calendar state for create, edit, and delete actions. If remote CalDAV write-back is implemented, perform that action immediately after the local update within the same user flow to ensure consistency. Avoid a design where local edits are at risk of being overwritten by a later remote sync without user awareness.
## Refactoring Requirement
If calendar sync code is touched, strongly prefer moving reusable sync logic out of `action.php` into a dedicated class under `src/`.
Reason:
- `action.php` currently mixes hook registration, rendering-related behavior, and calendar sync handling.
- Reusable sync and event mutation logic will be easier to test, reuse, and extend from a dedicated class.
## Expected Deliverables
1. Implement the clearly defined items that can be completed safely from the current codebase.
2. Mark any blocked items with precise clarification questions instead of guessing.
3. Update `README.md` for any new syntax, permissions, or workflow changes.
4. Run `php -l` on changed PHP files.
5. Summarize remaining design decisions, especially around permissions, recurring events, and remote CalDAV write-back.