diff --git a/README.md b/README.md
index 317d99a..108421c 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
# Calendar plugin for wordpress site
Status as of 2026-03-31 when codex credit ran out:
-1. Removal of plugin did not work. Need to set all files owner to www-data.
-2. Deletion of event in ui doesn't delete event in thunderbird
-3. Cannot subscribe to an empty calendar in thunderbird
-4. Click inside month cell doesn't add event.
-5. Login pane hidden under website hero/banner image
+1. Removal of plugin did not work. Need to set all files owner to www-data. - done
+2. Deletion of event in ui doesn't delete event in thunderbird - done
+3. Cannot subscribe to an empty calendar in thunderbird - ok
+4. Click inside month cell doesn't add event. - cannot reproduce
+5. Login pane hidden under website hero/banner image - done
6. Updating an events description via caldav creates weird sequences, e.g.
-a space ends up as: text/html,%C2%A0":
+ a space ends up as: text/html,%C2%A0":
+7. In a private window, Click on event from not-logged-in calendar page shows event as a dialog box, it should show it as a panel with a subset of the edit event panel - i.e. the title, category, location, start and end and description. -
+8.
diff --git a/code/calendar-plugin.php b/code/calendar-plugin.php
index 8abffcf..b37b95d 100644
--- a/code/calendar-plugin.php
+++ b/code/calendar-plugin.php
@@ -3,7 +3,7 @@
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
- * Version: 0.1.15
+ * Version: 1.0.1
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)
diff --git a/code/src/Domain/IcsService.php b/code/src/Domain/IcsService.php
index 7927cdf..8749c24 100644
--- a/code/src/Domain/IcsService.php
+++ b/code/src/Domain/IcsService.php
@@ -346,7 +346,7 @@ final class IcsService
if (!$in) {
continue;
}
- [$left, $value] = array_pad(explode(':', $line, 2), 2, '');
+ [$left, $value] = $this->splitContentLine($line);
if ($left === '') {
continue;
}
@@ -364,6 +364,23 @@ final class IcsService
return $in ? $props : null;
}
+ private function splitContentLine(string $line): array
+ {
+ $inQuotes = false;
+ $len = strlen($line);
+ for ($i = 0; $i < $len; $i++) {
+ $ch = $line[$i];
+ if ($ch === '"') {
+ $inQuotes = !$inQuotes;
+ continue;
+ }
+ if ($ch === ':' && !$inQuotes) {
+ return [substr($line, 0, $i), substr($line, $i + 1)];
+ }
+ }
+ return [$line, ''];
+ }
+
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);
diff --git a/code/src/Plugin.php b/code/src/Plugin.php
index b328951..cc66c90 100644
--- a/code/src/Plugin.php
+++ b/code/src/Plugin.php
@@ -180,10 +180,10 @@ final class Plugin
';
}
- private function formatSidebarTimeRange(int $startTs, int $endTs): string
+ private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
{
- $startMeridiem = strtolower(date('a', $startTs));
- $endMeridiem = strtolower(date('a', $endTs));
- $startLabel = $this->formatSidebarTimeValue($startTs);
- $endLabel = $this->formatSidebarTimeValue($endTs);
+ if ($value === '') {
+ return null;
+ }
+ try {
+ return (new \DateTimeImmutable($value))->setTimezone($timezone);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
+ {
+ $startMeridiem = strtolower($startDt->format('a'));
+ $endMeridiem = strtolower($endDt->format('a'));
+ $startLabel = $this->formatSidebarTimeValue($startDt);
+ $endLabel = $this->formatSidebarTimeValue($endDt);
if ($startMeridiem === $endMeridiem) {
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
return $startLabel . '–' . $endLabel;
@@ -1023,11 +1070,11 @@ HTML
return $startLabel . '–' . $endLabel;
}
- private function formatSidebarTimeValue(int $ts): string
+ private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
{
- $hour = (int) date('G', $ts);
- $minute = (int) date('i', $ts);
- $meridiem = strtolower(date('a', $ts));
+ $hour = (int) $dt->format('G');
+ $minute = (int) $dt->format('i');
+ $meridiem = strtolower($dt->format('a'));
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
@@ -1375,7 +1422,7 @@ HTML
return [
'status' => 'ok',
'plugin' => 'calendar-plugin',
- 'version' => '0.1.15',
+ 'version' => '1.0.1',
'db_prefix' => $this->db->getPrefix(),
];
},
diff --git a/requirements/caldav.md b/requirements/caldav.md
index c5d7b5d..74396e9 100644
--- a/requirements/caldav.md
+++ b/requirements/caldav.md
@@ -104,6 +104,7 @@ Minimum mapping expectations:
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
- Recurrence rules -> `RRULE`
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
+- Parsing must correctly handle quoted property parameters containing `:` (for example `DESCRIPTION;ALTREP="data:text/html,..."`) so field values are not polluted by parameter content.
Privacy visibility mapping:
diff --git a/requirements/deployment.md b/requirements/deployment.md
index a165cb0..2ffe93d 100644
--- a/requirements/deployment.md
+++ b/requirements/deployment.md
@@ -44,11 +44,16 @@ Before deployment:
## Deployment Procedure Requirements
Required high-level procedure:
-1. Transfer approved artifact to remote host staging area.
-2. Extract artifact to a clean temporary directory on remote host.
-3. Validate extracted plugin directory structure.
-4. Synchronize extracted plugin directory to deploy directory.
-5. Run post-deploy verification checks.
+1. Build a fresh deploy artifact as part of deploy execution (package step) using the current repository state.
+2. Transfer approved artifact to remote host staging area.
+3. Extract artifact to a clean temporary directory on remote host.
+4. Validate extracted plugin directory structure.
+5. Synchronize extracted plugin directory to deploy directory.
+6. Run post-deploy verification checks.
+
+Additional policy requirement:
+- Deploy flow should not bypass packaging by deploying an arbitrary stale artifact path; deployment must use the freshly built artifact for that deploy run.
+- Deployment tooling may support an explicit version override for controlled releases (for example `1.0.0`); when used, that explicit version must be the packaged and deployed artifact version for that run.
## Exact-Match Validation (Required)
After deployment, deployed plugin files must exactly match the approved artifact contents (excluding allowed mutable runtime files if any are explicitly listed).
diff --git a/requirements/packaging.md b/requirements/packaging.md
index 4cb9956..5a84c9d 100644
--- a/requirements/packaging.md
+++ b/requirements/packaging.md
@@ -61,8 +61,10 @@ Required steps:
1. Create/clean a staging folder under `package/`.
2. Copy approved runtime files from `code/` into staging.
3. Apply exclusion rules to remove non-runtime artifacts.
-4. Create a versioned zip archive in `package/`.
-5. Record artifact name and version in release notes/changelog.
+4. Increment plugin patch version (`X.Y.Z -> X.Y.(Z+1)`) for each package build unless an explicit version override is provided.
+5. Keep runtime-visible version fields synchronized for the package (plugin header version and health/API version metadata).
+6. Create a versioned zip archive in `package/`.
+7. Record artifact name and version in release notes/changelog.
Artifact naming requirement:
diff --git a/requirements/web-ui.md b/requirements/web-ui.md
index efbc96b..e1a7ea2 100644
--- a/requirements/web-ui.md
+++ b/requirements/web-ui.md
@@ -83,6 +83,15 @@ Minimum display requirements:
- Category (if provided)
- Description excerpt/summary (if configured for display)
+List-view formatting requirements:
+
+- In `list` view, each row headline must present date, time range (or all-day marker), then title in readable natural-language order.
+- Date formatting in `list` view should use long-form style (for example `5 April 2026`) rather than compact numeric-only format.
+- Time range formatting in `list` view should be compact and human-readable (for example `9–10am`).
+- Title must be sourced from event title data and must not be replaced by description text.
+- If description is shown in `list` view, it should appear as secondary text below the headline.
+- `list` view rows must not display default browser list bullets.
+
Privacy display rules:
- Public events render full details per normal display rules.
@@ -119,6 +128,9 @@ Requirements:
- Single-occurrence selection must be user-friendly via a date list or compact calendar selection.
- The compact selection mode should support a 3-month grid with previous/next navigation.
- The compact selection grid must remain readable in modal layout.
+- Login, Event Details, and Event Editor overlays must render above site/theme chrome (for example header/banner artwork) and remain interactable without requiring page scroll workarounds.
+- Login overlay should be centered within the viewport on desktop and mobile.
+- Event click interactions must use in-page overlays and must not fall back to browser-native dialog boxes.
## Login and Access Modes
- `/calendar` must support two user modes: public (not logged in) and logged-in.
@@ -131,6 +143,8 @@ Requirements:
- Login dialog must support password-reset request initiation.
- Logged-in but non-approved users remain read-only.
- Approved users can perform event CRUD.
+- Event-click behavior must be consistent between normal and private/incognito browser sessions.
+- In public mode, clicking an event must open the Event Details overlay (not the Event Editor overlay).
## ICS Link in Web UI
The UI must include a user-visible link to an ICS representation of calendar data.
@@ -162,3 +176,5 @@ Acceptance should verify:
- Empty-state behavior is clear and user-friendly.
- ICS link is present and returns valid calendar payload.
- Privacy redaction behavior is correct in public views, sidebar, and logged-in views.
+- In both normal and private/incognito sessions, clicking an event in public mode opens the same Event Details overlay.
+- Login/Event Details/Event Editor overlays remain above site header/banner layers and are fully usable without scrolling to bypass theme artwork.
diff --git a/scripts/deploy_remote.sh b/scripts/deploy_remote.sh
index 5205be1..1f1aa86 100755
--- a/scripts/deploy_remote.sh
+++ b/scripts/deploy_remote.sh
@@ -11,6 +11,7 @@ fi
ARTIFACT=""
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
+VERSION_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -22,17 +23,24 @@ while [[ $# -gt 0 ]]; do
WP_ROOT="${2:-}"
shift 2
;;
+ --version)
+ VERSION_OVERRIDE="${2:-}"
+ shift 2
+ ;;
--help|-h)
cat <<'USAGE'
Deploy a plugin artifact to remote WordPress.
Usage:
- scripts/deploy_remote.sh [--artifact /abs/or/relative/path.zip] [--wp-root /var/www/wordpress]
+ scripts/deploy_remote.sh [--wp-root /var/www/wordpress] [--version X.Y.Z]
Defaults:
-- Artifact: latest ./package/calendar-plugin-*.zip
+- Always builds a fresh package first (which auto-bumps patch version)
- Remote host settings from credentials/.env
+Notes:
+- Use `--version` for explicit release version packaging (for example `1.0.0`).
+
This script enforces ownership:
- chown -R www-data:www-data
USAGE
@@ -45,10 +53,20 @@ USAGE
esac
done
-if [[ -z "${ARTIFACT}" ]]; then
- ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
+if [[ -n "${ARTIFACT}" ]]; then
+ echo "[deploy] --artifact is not supported; deploy always builds a fresh package with bumped patch version" >&2
+ exit 2
fi
+if [[ -n "${VERSION_OVERRIDE}" ]]; then
+ echo "[deploy] packaging before deployment (explicit version ${VERSION_OVERRIDE})"
+ "${ROOT_DIR}/scripts/package_plugin.sh" --version "${VERSION_OVERRIDE}"
+else
+ echo "[deploy] packaging before deployment (auto patch bump)"
+ "${ROOT_DIR}/scripts/package_plugin.sh"
+fi
+
+ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -z "${ARTIFACT}" ]]; then
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
exit 1
diff --git a/scripts/package_plugin.sh b/scripts/package_plugin.sh
index 054db76..116ae82 100755
--- a/scripts/package_plugin.sh
+++ b/scripts/package_plugin.sh
@@ -36,15 +36,25 @@ USAGE
esac
done
-if [[ -z "${VERSION}" ]]; then
- VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
-fi
+CURRENT_VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
-if [[ -z "${VERSION}" ]]; then
+if [[ -z "${CURRENT_VERSION}" ]]; then
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
exit 1
fi
+if [[ -z "${VERSION}" ]]; then
+ if [[ "${CURRENT_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
+ VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
+ else
+ echo "[package] current version is not semantic (X.Y.Z): ${CURRENT_VERSION}" >&2
+ exit 1
+ fi
+fi
+
+sed -Ei "s/^ \* Version: .*/ * Version: ${VERSION}/" "${ROOT_DIR}/code/calendar-plugin.php"
+sed -Ei "s/('version' => ')[^']+(',)/\1${VERSION}\2/" "${ROOT_DIR}/code/src/Plugin.php"
+
SLUG="calendar-plugin"
PACKAGE_DIR="${ROOT_DIR}/package"
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
@@ -67,5 +77,6 @@ rsync -a --delete "${ROOT_DIR}/code/" "${STAGING_DIR}/"
find . -type f -print0 | sort -z | xargs -0 sha256sum
) > "${MANIFEST}"
+echo "[package] version: ${CURRENT_VERSION} -> ${VERSION}"
echo "[package] created artifact: ${ARTIFACT}"
echo "[package] created manifest: ${MANIFEST}"
diff --git a/tests/run_remote_tests.sh b/tests/run_remote_tests.sh
index 381b237..c9a41e8 100755
--- a/tests/run_remote_tests.sh
+++ b/tests/run_remote_tests.sh
@@ -401,6 +401,64 @@ if [[ "${CALDAV_COLLECTION_GET}" != "200" ]]; then
record_fail "caldav collection GET expected 200 got ${CALDAV_COLLECTION_GET} (${CALDAV_COLLECTION_URL})"
fi
+step "caldav DESCRIPTION ALTREP parsing regression"
+ALTREP_UID="remote-caldav-altrep-$(date +%s)@calendar-plugin"
+ALTREP_RESOURCE="remote-altrep-$(date +%s).ics"
+TMP_ALTREP_ICS="$(mktemp)"
+cat > "${TMP_ALTREP_ICS}" </tmp/remote_test_altrep_event.json; then
+ record_fail "caldav ALTREP event fetch failed"
+ elif ! json_assert /tmp/remote_test_altrep_event.json "data.get('data', {}).get('description') == 'test'"; then
+ record_fail "caldav ALTREP description parse regression (expected 'test')"
+ fi
+ curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
+ "${BASE_URL}/wp-json/calendar/v1/events/${ALTREP_EVENT_ID}" >/dev/null || true
+ fi
+fi
+
step "caldav sync-collection stability"
SYNC_UID="remote-caldav-sync-del-$(date +%s)@calendar-plugin"
SYNC_CREATE_JSON=$(cat <