calendar-plugin/fixture/server.py

4231 lines
193 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Local fixture harness for the calendar plugin requirements.
Commands:
python3 fixture/server.py init
python3 fixture/server.py reset
python3 fixture/server.py seed
python3 fixture/server.py run --host 127.0.0.1 --port 8080
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import smtplib
import sqlite3
import ssl
import textwrap
import time
import calendar as pycalendar
from collections import deque
from email.message import EmailMessage
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, unquote, urlparse
ROOT = Path(__file__).resolve().parent
DB_PATH = ROOT / "fixture.db"
DEFAULT_TIMEZONE = "Europe/London"
SHARED_CALENDAR_ID = "public"
API_BASE = "/wp-json/calendar/v1"
PBKDF2_ITERATIONS = 210_000
VERIFY_TOKEN_TTL_SECONDS = 24 * 60 * 60
RESET_TOKEN_TTL_SECONDS = 30 * 60
_RATE_LIMIT_STATE: Dict[str, List[float]] = {}
def fixture_trace_log_path() -> Path:
configured = (os.getenv("FIXTURE_HTTP_TRACE_LOG") or "").strip()
if configured:
p = Path(configured)
return p if p.is_absolute() else (ROOT / p)
return ROOT / "http_trace.log"
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def hash_password(value: str) -> str:
# Stronger hash format for fixture: pbkdf2_sha256$iterations$salt_hex$digest_hex
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", value.encode("utf-8"), salt, PBKDF2_ITERATIONS)
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def verify_password(value: str, stored_hash: str) -> bool:
if not stored_hash:
return False
if stored_hash.startswith("pbkdf2_sha256$"):
parts = stored_hash.split("$")
if len(parts) != 4:
return False
try:
iterations = int(parts[1])
salt = bytes.fromhex(parts[2])
expected = bytes.fromhex(parts[3])
except ValueError:
return False
actual = hashlib.pbkdf2_hmac("sha256", value.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)
# Backward-compatible legacy support for old unsalted SHA-256 hashes.
if re.fullmatch(r"[0-9a-f]{64}", stored_hash):
legacy = hashlib.sha256(value.encode("utf-8")).hexdigest()
return hmac.compare_digest(legacy, stored_hash)
return False
def _rate_limited(scope: str, key: str, limit: int, window_seconds: int) -> bool:
now = time.monotonic()
bucket = f"{scope}:{key}"
hits = _RATE_LIMIT_STATE.get(bucket, [])
cutoff = now - window_seconds
hits = [t for t in hits if t >= cutoff]
blocked = len(hits) >= limit
hits.append(now)
_RATE_LIMIT_STATE[bucket] = hits[-max(limit * 2, 16) :]
return blocked
def mk_etag(seed: str) -> str:
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
return f'"{digest}"'
def parse_basic_auth(header_value: Optional[str]) -> Optional[Tuple[str, str]]:
if not header_value or not header_value.startswith("Basic "):
return None
encoded = header_value[6:].strip()
try:
raw = base64.b64decode(encoded).decode("utf-8")
except Exception:
return None
if ":" not in raw:
return None
username, password = raw.split(":", 1)
return username, password
def _read_env_file(path: Path) -> Dict[str, str]:
out: Dict[str, str] = {}
if not path.exists():
return out
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
out[key.strip()] = value.strip().strip('"').strip("'")
return out
def _smtp_settings() -> Dict[str, str]:
env = _read_env_file(ROOT.parent / "credentials" / ".env")
merged = dict(env)
merged.update({k: v for k, v in os.environ.items() if k.startswith("SMTP_")})
return merged
def send_fixture_email(to_email: str, subject: str, body_text: str) -> Tuple[bool, str]:
cfg = _smtp_settings()
host = (cfg.get("SMTP_HOST") or "").strip()
port = int((cfg.get("SMTP_PORT") or "587").strip())
username = (cfg.get("SMTP_USERNAME") or "").strip()
password = cfg.get("SMTP_PASSWORD") or ""
from_email = (cfg.get("SMTP_FROM") or "").strip()
use_tls = (cfg.get("SMTP_USE_TLS") or "true").strip().lower() in {"1", "true", "yes", "on"}
if not host or not from_email or not to_email:
return False, "smtp_not_configured"
msg = EmailMessage()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(body_text)
try:
with smtplib.SMTP(host, port, timeout=10) as server:
if use_tls:
server.starttls(context=ssl.create_default_context())
if username:
server.login(username, password)
server.send_message(msg)
return True, "sent"
except Exception as exc: # pragma: no cover - environment dependent
return False, f"smtp_error:{exc.__class__.__name__}:{exc}"
def ensure_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def create_schema(conn: sqlite3.Connection) -> None:
cur = conn.cursor()
cur.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
all_day_event INTEGER NOT NULL DEFAULT 0,
start_datetime TEXT NOT NULL,
end_datetime TEXT NOT NULL,
repeat_type TEXT NOT NULL DEFAULT 'none',
repeat_interval INTEGER NOT NULL DEFAULT 1,
repeat_nth_mode TEXT NOT NULL DEFAULT '',
repeat_nth_day INTEGER NULL,
repeat_nth_pos INTEGER NULL,
repeat_nth_weekday INTEGER NULL,
repeat_range_mode TEXT NOT NULL DEFAULT 'none',
repeat_count INTEGER NULL,
repeat_until TEXT NULL,
timezone TEXT NOT NULL DEFAULT 'Europe/London',
calendar_id TEXT NOT NULL DEFAULT 'public',
etag TEXT NOT NULL,
sync_version INTEGER NOT NULL DEFAULT 1,
last_modified_by_user_id INTEGER NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recurrence_exceptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
occurrence_key TEXT NOT NULL,
exception_type TEXT NOT NULL,
override_payload TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (event_id, occurrence_key)
);
CREATE TABLE IF NOT EXISTS caldav_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified_at TEXT NULL,
account_status TEXT NOT NULL,
access_level TEXT NOT NULL,
request_state TEXT NOT NULL DEFAULT 'none',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_type TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_type TEXT NOT NULL,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
result TEXT NOT NULL,
context_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
if "caldav_resource" not in cols:
conn.execute("ALTER TABLE events ADD COLUMN caldav_resource TEXT NULL")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_events_caldav_resource ON events(caldav_resource)")
conn.execute("UPDATE events SET caldav_resource = id || '.ics' WHERE caldav_resource IS NULL")
conn.commit()
def drop_all(conn: sqlite3.Connection) -> None:
cur = conn.cursor()
cur.executescript(
"""
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS user_tokens;
DROP TABLE IF EXISTS caldav_users;
DROP TABLE IF EXISTS recurrence_exceptions;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS plugin_settings;
"""
)
conn.commit()
def log_audit(
conn: sqlite3.Connection,
actor_type: str,
actor_id: str,
action: str,
target_type: str,
target_id: str,
result: str,
context: Dict[str, Any],
) -> None:
conn.execute(
"""
INSERT INTO audit_log (actor_type, actor_id, action, target_type, target_id, result, context_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
actor_type,
actor_id,
action,
target_type,
target_id,
result,
json.dumps(context, sort_keys=True),
utc_now_iso(),
),
)
conn.commit()
def seed_data(conn: sqlite3.Connection) -> None:
now = utc_now_iso()
users = [
("rw_user@example.test", "rwpass123456", now, "active", "write", "approved"),
("adrians@chezstephens.org.uk", "brillig1", now, "active", "write", "approved"),
("pending_user@example.test", "pending123456", now, "pending_approval", "write", "requested"),
]
for email, pw, verified, status, level, request_state in users:
conn.execute(
"""
INSERT OR IGNORE INTO caldav_users
(email, password_hash, email_verified_at, account_status, access_level, request_state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(email, hash_password(pw), verified, status, level, request_state, now, now),
)
fixture_events: List[Dict[str, Any]] = [
{
"id": 1,
"uid": "fixture-ce-001@calendar-wp-plugin",
"title": "Board Meeting",
"description": "Quarterly board review.",
"location": "Room A",
"category": "Governance",
"all_day_event": 0,
"start_datetime": "2026-04-01T10:00:00+01:00",
"end_datetime": "2026-04-01T11:30:00+01:00",
"repeat_type": "none",
"repeat_interval": 1,
"repeat_range_mode": "none",
"repeat_count": None,
"repeat_until": None,
},
{
"id": 2,
"uid": "fixture-ce-002@calendar-wp-plugin",
"title": "Office Closed",
"description": "Public holiday closure.",
"location": "HQ",
"category": "Operations",
"all_day_event": 1,
"start_datetime": "2026-05-04",
"end_datetime": "2026-05-05",
"repeat_type": "none",
"repeat_interval": 1,
"repeat_range_mode": "none",
"repeat_count": None,
"repeat_until": None,
},
{
"id": 3,
"uid": "fixture-ce-003@calendar-wp-plugin",
"title": "Daily Standup",
"description": "15 minute sync.",
"location": "Online",
"category": "Team",
"all_day_event": 0,
"start_datetime": "2026-04-06T09:00:00+01:00",
"end_datetime": "2026-04-06T09:15:00+01:00",
"repeat_type": "daily",
"repeat_interval": 1,
"repeat_range_mode": "count",
"repeat_count": 10,
"repeat_until": None,
},
{
"id": 4,
"uid": "fixture-ce-004@calendar-wp-plugin",
"title": "Community Lunch",
"description": "Weekly community lunch.",
"location": "Cafeteria",
"category": "Community",
"all_day_event": 0,
"start_datetime": "2026-04-08T12:30:00+01:00",
"end_datetime": "2026-04-08T13:30:00+01:00",
"repeat_type": "weekly",
"repeat_interval": 1,
"repeat_range_mode": "no_end",
"repeat_count": None,
"repeat_until": None,
},
{
"id": 5,
"uid": "fixture-ce-005@calendar-wp-plugin",
"title": "Finance Close",
"description": "Month-end close process.",
"location": "Finance Office",
"category": "Finance",
"all_day_event": 0,
"start_datetime": "2026-04-30T17:00:00+01:00",
"end_datetime": "2026-04-30T18:00:00+01:00",
"repeat_type": "monthly",
"repeat_interval": 1,
"repeat_range_mode": "until",
"repeat_count": None,
"repeat_until": "2026-08-31",
},
{
"id": 6,
"uid": "fixture-ce-006@calendar-wp-plugin",
"title": "Annual Conference",
"description": "Annual community conference.",
"location": "Main Hall",
"category": "Events",
"all_day_event": 0,
"start_datetime": "2026-06-15T10:00:00+01:00",
"end_datetime": "2026-06-15T17:00:00+01:00",
"repeat_type": "yearly",
"repeat_interval": 1,
"repeat_range_mode": "count",
"repeat_count": 3,
"repeat_until": None,
},
{
"id": 7,
"uid": "fixture-ce-007@calendar-wp-plugin",
"title": "Fortnightly Coaching",
"description": "Coaching check-in.",
"location": "Online",
"category": "Training",
"all_day_event": 0,
"start_datetime": "2026-04-07T15:00:00+01:00",
"end_datetime": "2026-04-07T16:00:00+01:00",
"repeat_type": "custom",
"repeat_interval": 2,
"repeat_range_mode": "until",
"repeat_count": None,
"repeat_until": "2026-07-31",
},
{
"id": 8,
"uid": "fixture-ce-008@calendar-wp-plugin",
"title": "DST Validation Event",
"description": "Validates DST transition rendering.",
"location": "Lab",
"category": "QA",
"all_day_event": 0,
"start_datetime": "2026-10-25T00:30:00+01:00",
"end_datetime": "2026-10-25T02:30:00+00:00",
"repeat_type": "none",
"repeat_interval": 1,
"repeat_range_mode": "none",
"repeat_count": None,
"repeat_until": None,
},
{
"id": 9,
"uid": "fixture-ce-009@calendar-wp-plugin",
"title": "Leap Day Marker",
"description": "Leap day recurrence behavior.",
"location": "Calendar",
"category": "QA",
"all_day_event": 0,
"start_datetime": "2028-02-29T09:00:00+00:00",
"end_datetime": "2028-02-29T10:00:00+00:00",
"repeat_type": "yearly",
"repeat_interval": 1,
"repeat_range_mode": "count",
"repeat_count": 3,
"repeat_until": None,
},
{
"id": 10,
"uid": "fixture-ce-010@calendar-wp-plugin",
"title": "Therapy Session",
"description": "Used for single-occurrence delete exception tests.",
"location": "Clinic",
"category": "Health",
"all_day_event": 0,
"start_datetime": "2026-04-03T14:00:00+01:00",
"end_datetime": "2026-04-03T15:00:00+01:00",
"repeat_type": "weekly",
"repeat_interval": 1,
"repeat_range_mode": "count",
"repeat_count": 8,
"repeat_until": None,
},
]
for event in fixture_events:
etag = mk_etag(f"{event['uid']}:{now}:1")
conn.execute(
"""
INSERT OR REPLACE INTO events
(id, uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
repeat_range_mode, repeat_count, repeat_until, timezone,
calendar_id, caldav_resource, etag, sync_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event["id"],
event["uid"],
event["title"],
event["description"],
event["location"],
event["category"],
event["all_day_event"],
event["start_datetime"],
event["end_datetime"],
event["repeat_type"],
event["repeat_interval"],
event.get("repeat_nth_mode", ""),
event.get("repeat_nth_day"),
event.get("repeat_nth_pos"),
event.get("repeat_nth_weekday"),
event["repeat_range_mode"],
event["repeat_count"],
event["repeat_until"],
DEFAULT_TIMEZONE,
SHARED_CALENDAR_ID,
f"{event['id']}.ics",
etag,
1,
now,
now,
),
)
conn.execute("DELETE FROM recurrence_exceptions")
conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("presentation_name", "Calendar", now))
conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("url_slug", "", now))
conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("ics_access_mode", "public_read", now))
conn.execute("INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)", ("caldav_calendar_name", "Public Calendar", now))
conn.commit()
def event_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
return {
"id": row["id"],
"uid": row["uid"],
"title": row["title"],
"description": row["description"],
"location": row["location"],
"category": row["category"],
"all_day_event": bool(row["all_day_event"]),
"start_datetime": row["start_datetime"],
"end_datetime": row["end_datetime"],
"repeat_type": row["repeat_type"],
"repeat_interval": row["repeat_interval"],
"repeat_nth_mode": row["repeat_nth_mode"],
"repeat_nth_day": row["repeat_nth_day"],
"repeat_nth_pos": row["repeat_nth_pos"],
"repeat_nth_weekday": row["repeat_nth_weekday"],
"repeat_range_mode": row["repeat_range_mode"],
"repeat_count": row["repeat_count"],
"repeat_until": row["repeat_until"],
"timezone": row["timezone"],
"calendar_id": row["calendar_id"],
"etag": row["etag"],
"sync_version": row["sync_version"],
"updated_at": row["updated_at"],
}
def get_setting(conn: sqlite3.Connection, key: str, default_value: str) -> str:
row = conn.execute("SELECT value FROM plugin_settings WHERE key = ?", (key,)).fetchone()
if not row:
return default_value
return row["value"]
def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
conn.execute(
"INSERT OR REPLACE INTO plugin_settings (key, value, updated_at) VALUES (?, ?, ?)",
(key, value, utc_now_iso()),
)
conn.commit()
def _parse_dt_maybe_date(value: str) -> datetime:
if "T" in value:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
return datetime.fromisoformat(f"{value}T00:00:00+00:00")
def _canonical_occurrence_key(value: str) -> Optional[str]:
raw = (value or "").strip()
if not raw:
return None
try:
dt = _parse_dt_maybe_date(raw)
except ValueError:
return None
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat()
def _normalize_occurrence_key_for_event(value: str, event_start_iso: str) -> Optional[str]:
raw = (value or "").strip()
if not raw:
return None
if "T" in raw:
return _canonical_occurrence_key(raw)
# Allow date-only delete keys by inheriting time+offset from event start.
try:
base = _parse_dt_maybe_date(event_start_iso)
d = date.fromisoformat(raw)
except ValueError:
return None
candidate = base.replace(year=d.year, month=d.month, day=d.day)
return candidate.astimezone(timezone.utc).replace(microsecond=0).isoformat()
def _normalize_monthly_anchor(
start_iso: str,
end_iso: str,
repeat_type: str,
repeat_nth_mode: str,
repeat_nth_day: Optional[int],
repeat_nth_pos: Optional[int],
repeat_nth_weekday: Optional[int],
) -> Tuple[str, str]:
if repeat_type != "monthly":
return start_iso, end_iso
try:
start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
except ValueError:
return start_iso, end_iso
duration = end_dt - start_dt
adjusted_start = start_dt
if repeat_nth_mode == "day_of_month" and repeat_nth_day:
day = max(1, min(int(repeat_nth_day), pycalendar.monthrange(start_dt.year, start_dt.month)[1]))
adjusted_start = start_dt.replace(day=day)
elif repeat_nth_mode == "weekday_of_month" and repeat_nth_pos and repeat_nth_weekday is not None:
nth_day = _nth_weekday_of_month(start_dt.year, start_dt.month, int(repeat_nth_weekday), int(repeat_nth_pos))
if nth_day is not None:
adjusted_start = start_dt.replace(day=nth_day)
adjusted_end = adjusted_start + duration
return adjusted_start.isoformat(), adjusted_end.isoformat()
def _add_months(dt: datetime, months: int) -> datetime:
year = dt.year + (dt.month - 1 + months) // 12
month = (dt.month - 1 + months) % 12 + 1
day = min(dt.day, pycalendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def _add_years(dt: datetime, years: int) -> datetime:
try:
return dt.replace(year=dt.year + years)
except ValueError:
# Leap-day fallback
return dt.replace(month=2, day=28, year=dt.year + years)
def _nth_weekday_of_month(year: int, month: int, weekday: int, pos: int) -> Optional[int]:
# weekday: 0=Sunday..6=Saturday, pos: 1..5 or -1 (last)
if not (0 <= weekday <= 6 and (1 <= pos <= 5 or pos == -1)):
return None
py_weekday = (weekday - 1) % 7 # python: Monday=0..Sunday=6
if pos == -1:
days_in_month = pycalendar.monthrange(year, month)[1]
for day in range(days_in_month, 0, -1):
if date(year, month, day).weekday() == py_weekday:
return day
return None
day = 1
hits = 0
days_in_month = pycalendar.monthrange(year, month)[1]
while day <= days_in_month:
dt = date(year, month, day)
if dt.weekday() == py_weekday:
hits += 1
if hits == pos:
return day
day += 1
return None
def _window_for_view(view: str, anchor: date) -> Tuple[datetime, datetime]:
if view == "day":
start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
return start, start + timedelta(days=1)
if view == "week":
monday = anchor - timedelta(days=anchor.weekday())
start = datetime.fromisoformat(f"{monday.isoformat()}T00:00:00+00:00")
return start, start + timedelta(days=7)
if view == "month":
first = anchor.replace(day=1)
start = datetime.fromisoformat(f"{first.isoformat()}T00:00:00+00:00")
end = _add_months(start, 1)
return start, end
if view == "year":
first = date(anchor.year, 1, 1)
start = datetime.fromisoformat(f"{first.isoformat()}T00:00:00+00:00")
end = _add_years(start, 1)
return start, end
start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
return start - timedelta(days=30), start + timedelta(days=90)
def _occurrence_overlap(s1: datetime, e1: datetime, s2: datetime, e2: datetime) -> bool:
return s1 < e2 and e1 > s2
def make_uid(seed: str) -> str:
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:20]
return f"{digest}@calendar-wp-plugin"
def create_ics_event(event: Dict[str, Any], exdates: List[str]) -> str:
lines = [
"BEGIN:VEVENT",
f"UID:{event['uid']}",
f"DTSTAMP:{_to_ics_ts(utc_now_iso())}",
f"SUMMARY:{_ics_escape(event['title'])}",
]
if event.get("description"):
lines.append(f"DESCRIPTION:{_ics_escape(event['description'])}")
if event.get("location"):
lines.append(f"LOCATION:{_ics_escape(event['location'])}")
if event.get("category"):
lines.append(f"CATEGORIES:{_ics_escape(event['category'])}")
if event.get("all_day_event"):
lines.append(f"DTSTART;VALUE=DATE:{_to_ics_date(event['start_datetime'])}")
lines.append(f"DTEND;VALUE=DATE:{_to_ics_date(event['end_datetime'])}")
else:
lines.append(f"DTSTART;TZID={DEFAULT_TIMEZONE}:{_to_ics_local(event['start_datetime'])}")
lines.append(f"DTEND;TZID={DEFAULT_TIMEZONE}:{_to_ics_local(event['end_datetime'])}")
rrule = _build_rrule(event)
if rrule:
lines.append(f"RRULE:{rrule}")
if exdates:
start_tz = None
try:
start_tz = datetime.fromisoformat(event["start_datetime"].replace("Z", "+00:00")).tzinfo
except ValueError:
start_tz = None
normalized_exdates = []
for x in exdates:
try:
dt = datetime.fromisoformat(x.replace("Z", "+00:00"))
if start_tz is not None and dt.tzinfo is not None:
dt = dt.astimezone(start_tz)
normalized_exdates.append(dt.strftime("%Y%m%dT%H%M%S"))
except ValueError:
continue
joined = ",".join(normalized_exdates)
if joined:
lines.append(f"EXDATE;TZID={DEFAULT_TIMEZONE}:{joined}")
lines.append("END:VEVENT")
return "\r\n".join(lines)
def _build_rrule(event: Dict[str, Any]) -> Optional[str]:
freq_map = {
"daily": "DAILY",
"weekly": "WEEKLY",
"monthly": "MONTHLY",
"yearly": "YEARLY",
"custom": "WEEKLY", # First-pass simplification for fixture.
}
repeat_type = event.get("repeat_type")
if repeat_type not in freq_map or repeat_type == "none":
return None
parts = [f"FREQ={freq_map[repeat_type]}"]
interval = int(event.get("repeat_interval") or 1)
if interval > 1:
parts.append(f"INTERVAL={interval}")
if repeat_type == "monthly":
nth_mode = event.get("repeat_nth_mode") or ""
if nth_mode == "day_of_month" and event.get("repeat_nth_day"):
parts.append(f"BYMONTHDAY={int(event['repeat_nth_day'])}")
elif nth_mode == "weekday_of_month" and event.get("repeat_nth_pos") and event.get("repeat_nth_weekday") is not None:
weekday_map = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
pos = int(event["repeat_nth_pos"])
wd = int(event["repeat_nth_weekday"])
if 0 <= wd <= 6 and (1 <= pos <= 5 or pos == -1):
parts.append(f"BYDAY={weekday_map[wd]}")
parts.append(f"BYSETPOS={pos}")
mode = event.get("repeat_range_mode")
if mode == "count" and event.get("repeat_count"):
parts.append(f"COUNT={int(event['repeat_count'])}")
elif mode == "until" and event.get("repeat_until"):
# date-only UNTIL for first pass
until = event["repeat_until"].replace("-", "")
parts.append(f"UNTIL={until}T235959")
return ";".join(parts)
def _to_ics_ts(iso_ts: str) -> str:
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.strftime("%Y%m%dT%H%M%SZ")
def _to_ics_local(iso_ts: str) -> str:
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
return dt.strftime("%Y%m%dT%H%M%S")
def _to_ics_date(iso_ts: str) -> str:
if "T" in iso_ts:
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
return dt.strftime("%Y%m%d")
return iso_ts.replace("-", "")
def _ics_escape(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", r"\;")
.replace(",", r"\,")
.replace("\n", r"\n")
)
@dataclass
class AuthContext:
actor_type: str
actor_id: str
role: str
class _TraceWriter:
def __init__(self, handler: "FixtureHandler", raw):
self._handler = handler
self._raw = raw
def write(self, data):
self._handler._trace_capture_response_bytes(data)
return self._raw.write(data)
def flush(self):
return self._raw.flush()
def __getattr__(self, name):
return getattr(self._raw, name)
class FixtureHandler(BaseHTTPRequestHandler):
server_version = "CalendarFixture/0.1"
_trace_body_cap = 4096
_trace_body_preview_cap = 1200
def setup(self) -> None:
super().setup()
self.wfile = _TraceWriter(self, self.wfile)
self._trace_started = False
self._trace_request_body = b""
self._trace_response_status: Optional[int] = None
self._trace_response_headers: List[Tuple[str, str]] = []
self._trace_response_body = bytearray()
self._cached_request_body: Optional[bytes] = None
def _trace_start(self) -> None:
self._trace_started = True
self._trace_request_body = b""
self._trace_response_status = None
self._trace_response_headers = []
self._trace_response_body = bytearray()
self._cached_request_body = None
def _trace_capture_response_bytes(self, data: Any) -> None:
if not self._trace_started:
return
raw = data.encode("utf-8", errors="replace") if isinstance(data, str) else bytes(data)
remaining = self._trace_body_cap - len(self._trace_response_body)
if remaining > 0:
self._trace_response_body.extend(raw[:remaining])
def send_response(self, code: int, message: Optional[str] = None) -> None:
if getattr(self, "_trace_started", False):
self._trace_response_status = int(code)
super().send_response(code, message)
def send_header(self, keyword: str, value: str) -> None:
if getattr(self, "_trace_started", False):
self._trace_response_headers.append((keyword, value))
super().send_header(keyword, value)
def _trace_log_path(self) -> Path:
return fixture_trace_log_path()
def _redact_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
out: Dict[str, str] = {}
for k, v in headers.items():
if k.lower() in {"authorization", "cookie", "set-cookie"}:
out[k] = "***"
else:
out[k] = v
return out
def _sanitize_json_for_log(self, value: Any) -> Any:
if isinstance(value, dict):
out: Dict[str, Any] = {}
for k, v in value.items():
if str(k).lower() in {"password", "new_password", "token"}:
out[k] = "***"
else:
out[k] = self._sanitize_json_for_log(v)
return out
if isinstance(value, list):
return [self._sanitize_json_for_log(v) for v in value]
return value
def _decode_body_for_log(self, body: bytes, content_type: str) -> str:
if not body:
return ""
ct = (content_type or "").lower()
raw = body[: self._trace_body_cap]
text = raw.decode("utf-8", errors="replace")
if "application/json" in ct:
try:
compact = json.dumps(self._sanitize_json_for_log(json.loads(text)), ensure_ascii=True)
if len(compact) > self._trace_body_preview_cap:
return compact[: self._trace_body_preview_cap] + " ...(truncated)"
return compact
except json.JSONDecodeError:
return text[: self._trace_body_preview_cap]
if "text/html" in ct:
return f"[omitted html payload: {len(body)} bytes]"
if "application/xml" in ct or "text/xml" in ct:
return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
if "text/calendar" in ct:
return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
if ct.startswith("text/"):
return text[: self._trace_body_preview_cap] + (" ...(truncated)" if len(text) > self._trace_body_preview_cap else "")
return f"[omitted non-text payload: {len(body)} bytes, content-type={content_type or 'unknown'}]"
def _strip_http_response_preamble(self, raw: bytes) -> bytes:
if not raw.startswith(b"HTTP/"):
return raw
marker = b"\r\n\r\n"
idx = raw.find(marker)
if idx == -1:
return raw
return raw[idx + len(marker) :]
def _read_trace_entries(self, limit: int = 20) -> List[Dict[str, Any]]:
path = self._trace_log_path()
if not path.exists():
return []
out: deque[Dict[str, Any]] = deque(maxlen=max(1, min(limit, 200)))
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return list(out)
def _trace_finish(self) -> None:
if not self._trace_started:
return
req_headers = self._redact_headers({k: v for (k, v) in self.headers.items()})
resp_headers: Dict[str, str] = {}
for k, v in self._trace_response_headers:
resp_headers[k] = "***" if k.lower() == "set-cookie" else v
req_ct = req_headers.get("Content-Type", "")
resp_ct = resp_headers.get("Content-Type", "")
response_raw = bytes(self._trace_response_body)
response_body_raw = self._strip_http_response_preamble(response_raw)
entry = {
"ts": utc_now_iso(),
"client": self.client_address[0] if self.client_address else "",
"method": self.command,
"path": self.path,
"request": {
"headers": req_headers,
"body": self._decode_body_for_log(self._trace_request_body, req_ct),
"body_bytes": len(self._trace_request_body),
"body_truncated": len(self._trace_request_body) > self._trace_body_cap,
},
"response": {
"status": self._trace_response_status,
"headers": resp_headers,
"body": self._decode_body_for_log(response_body_raw, resp_ct),
"body_bytes": len(response_body_raw),
"body_truncated": len(self._trace_response_body) >= self._trace_body_cap,
},
}
path = self._trace_log_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=True) + "\n")
self._trace_started = False
def _read_request_body_bytes(self) -> bytes:
if self._cached_request_body is not None:
return self._cached_request_body
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0:
data = b""
else:
data = self.rfile.read(length)
self._cached_request_body = data
self._trace_request_body = data[: self._trace_body_cap + 1]
return data
def _slug_value(self) -> str:
conn = ensure_db()
try:
raw = get_setting(conn, "url_slug", "").strip()
finally:
conn.close()
raw = raw.strip("/")
return raw
def _slug_prefix(self) -> str:
slug = self._slug_value()
return f"/{slug}" if slug else ""
def _strip_slug(self, path: str) -> Optional[str]:
prefix = self._slug_prefix()
if not prefix:
return path
if path == prefix:
return "/"
if path.startswith(prefix + "/"):
return path[len(prefix) :]
return None
def _with_slug(self, path: str) -> str:
prefix = self._slug_prefix()
return f"{prefix}{path}" if prefix else path
def _request_query(self) -> Dict[str, List[str]]:
return parse_qs(urlparse(self.path).query)
def _json_body(self) -> Dict[str, Any]:
data = self._read_request_body_bytes()
if not data:
return {}
return json.loads(data.decode("utf-8"))
def _write_json(self, status: int, payload: Dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _write_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _not_found(self) -> None:
self._write_json(404, {"error": {"code": "not_found", "message": "Route not found"}})
def _authenticate_wp_admin(self) -> Optional[AuthContext]:
# Fixture-only auth: header X-WP-User or query ?as=admin|editor.
wp_user = self.headers.get("X-WP-User", "")
if not wp_user:
wp_user = (self._request_query().get("as") or [""])[0]
if wp_user == "admin":
return AuthContext("wp_user", "admin", "wp_admin")
if wp_user == "editor":
return AuthContext("wp_user", "editor", "wp_editor")
return None
def _authenticate_caldav_user(self) -> Optional[AuthContext]:
creds = parse_basic_auth(self.headers.get("Authorization"))
if not creds:
return None
email, password = creds
conn = ensure_db()
row = conn.execute(
"""
SELECT id, email, password_hash, account_status, email_verified_at
FROM caldav_users WHERE email = ?
""",
(email,),
).fetchone()
if not row:
conn.close()
return None
if not verify_password(password, row["password_hash"]):
conn.close()
return None
if row["account_status"] != "active" or not row["email_verified_at"]:
conn.close()
return None
# Transparent legacy-hash upgrade on successful login.
if re.fullmatch(r"[0-9a-f]{64}", row["password_hash"]):
conn.execute(
"UPDATE caldav_users SET password_hash = ?, updated_at = ? WHERE id = ?",
(hash_password(password), utc_now_iso(), int(row["id"])),
)
conn.commit()
conn.close()
return AuthContext("caldav_user", str(row["id"]), "caldav_write")
def _require_api_admin(self) -> Optional[AuthContext]:
auth = self._authenticate_wp_admin()
if not auth:
self._write_json(401, {"error": {"code": "authentication_error", "message": "Admin authentication required"}})
return None
return auth
def _require_event_read_auth(self) -> Optional[AuthContext]:
wp = self._authenticate_wp_admin()
if wp:
return wp
caldav = self._authenticate_caldav_user()
if caldav:
return caldav
self._write_json(401, {"error": {"code": "authentication_error", "message": "Login required"}})
return None
def _require_event_write_auth(self) -> Optional[AuthContext]:
wp = self._authenticate_wp_admin()
if wp and wp.role in {"wp_admin", "wp_editor"}:
return wp
caldav = self._authenticate_caldav_user()
if caldav and caldav.role == "caldav_write":
return caldav
if wp or caldav:
self._write_json(403, {"error": {"code": "authorization_error", "message": "write capability required"}})
return None
self._write_json(401, {"error": {"code": "authentication_error", "message": "Login required"}})
return None
def _validate_event_payload(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
title = (payload.get("title") or "").strip()
start = payload.get("start_datetime")
end = payload.get("end_datetime")
if not title:
self._write_json(422, {"error": {"code": "validation_error", "message": "title is required"}})
return None
if not start or not end:
self._write_json(422, {"error": {"code": "validation_error", "message": "start_datetime and end_datetime are required"}})
return None
try:
ds = datetime.fromisoformat(start.replace("Z", "+00:00"))
de = datetime.fromisoformat(end.replace("Z", "+00:00"))
except ValueError:
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid datetime format"}})
return None
if de < ds:
self._write_json(422, {"error": {"code": "validation_error", "message": "end_datetime must be >= start_datetime"}})
return None
normalized = {
"title": title,
"description": payload.get("description", ""),
"location": payload.get("location", ""),
"category": payload.get("category", ""),
"all_day_event": 1 if payload.get("all_day_event") else 0,
"start_datetime": start,
"end_datetime": end,
"repeat_type": payload.get("repeat_type", "none"),
"repeat_interval": int(payload.get("repeat_interval", 1)),
"repeat_nth_mode": payload.get("repeat_nth_mode", "") or "",
"repeat_nth_day": payload.get("repeat_nth_day"),
"repeat_nth_pos": payload.get("repeat_nth_pos"),
"repeat_nth_weekday": payload.get("repeat_nth_weekday"),
"repeat_range_mode": payload.get("repeat_range_mode", "none"),
"repeat_count": payload.get("repeat_count"),
"repeat_until": payload.get("repeat_until"),
"timezone": payload.get("timezone", DEFAULT_TIMEZONE),
}
normalized_start, normalized_end = _normalize_monthly_anchor(
normalized["start_datetime"],
normalized["end_datetime"],
str(normalized["repeat_type"] or "none"),
str(normalized["repeat_nth_mode"] or ""),
int(normalized["repeat_nth_day"]) if normalized["repeat_nth_day"] not in (None, "") else None,
int(normalized["repeat_nth_pos"]) if normalized["repeat_nth_pos"] not in (None, "") else None,
int(normalized["repeat_nth_weekday"]) if normalized["repeat_nth_weekday"] not in (None, "") else None,
)
normalized["start_datetime"] = normalized_start
normalized["end_datetime"] = normalized_end
return normalized
def do_OPTIONS(self) -> None:
self._trace_start()
try:
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self.send_response(404)
self.end_headers()
return
if path.startswith("/caldav"):
self.send_response(200)
self.send_header("DAV", "1, 2, calendar-access")
self.send_header("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
self.end_headers()
return
self.send_response(200)
self.send_header("Allow", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
self.end_headers()
finally:
self._trace_finish()
def do_GET(self) -> None:
self._trace_start()
try:
parsed = urlparse(self.path)
path = self._strip_slug(parsed.path)
if path is None:
self._not_found()
return
if path == "/":
prefix = self._slug_prefix()
self._write_text(
200,
"calendar fixture is running\n"
f"slug prefix: {prefix or '(none)'}\n"
f"admin: {self._with_slug('/admin.php')}?as=admin\n"
f"public calendar: {self._with_slug('/calendar')}\n"
f"api: {self._with_slug('/wp-json/calendar/v1/events')}\n"
f"ics: {self._with_slug('/calendar.ics')}\n"
f"caldav: {self._with_slug('/caldav/')}\n",
)
return
if path in {"/admin.php", "/admin.php/"}:
self._handle_admin_page(parsed)
return
if path.startswith("/wp-admin/admin.php"):
self._handle_admin_page(parsed)
return
if path == "/calendar.ics":
self._handle_ics_get()
return
if path in {"/calendar", "/calendar/"}:
self._handle_public_calendar_page()
return
if path in {"/calendar-sidebar", "/calendar-sidebar/"}:
self._handle_sidebar_shortcode_page()
return
if path.startswith(API_BASE):
self._handle_api_get(path, parsed)
return
if path.startswith("/caldav/"):
self._handle_caldav_get(path)
return
self._not_found()
finally:
self._trace_finish()
def _base_page(self, title: str, body_html: str) -> str:
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{_html_escape(title)}</title>
<style>
body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #f7f7f5; color: #1f2937; }}
header {{ background: #0f766e; color: #fff; padding: 14px 18px; }}
header a {{ color: #fff; margin-right: 10px; }}
main {{ max-width: 1100px; margin: 18px auto; padding: 0 12px; }}
.card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 12px; }}
input, select, textarea, button {{ font: inherit; }}
button {{ padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; cursor: pointer; }}
button:disabled {{ opacity: 0.45; cursor: not-allowed; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ border-bottom: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; }}
.row {{ display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }}
.muted {{ color: #6b7280; font-size: 0.9rem; }}
.ok {{ color: #065f46; }}
.err {{ color: #991b1b; }}
.grid3 {{ display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:8px; }}
.grid4 {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:8px; }}
.field {{ position: relative; margin: 14px 0; }}
.field > label {{ position:absolute; top:-0.62rem; left:0.6rem; font-size:0.72rem; line-height:1; background:#fff; padding:0 0.25rem; color:#4b5563; letter-spacing:0.01em; }}
.field > input, .field > select, .field > textarea {{
width: 100%; box-sizing: border-box; padding: 0.78rem 0.65rem 0.5rem 0.65rem;
border: 1px solid #cbd5e1; border-radius: 8px; background: #fff;
}}
.field-with-suffix > input {{ padding-right: 5.5rem; }}
.input-suffix {{
position: absolute; right: 0.7rem; top: 56%;
transform: translateY(-50%); color: #374151; font-size: 0.84rem; pointer-events: none;
}}
.toolbar {{ display:flex; gap:8px; flex-wrap: wrap; align-items: end; }}
#topControls {{ align-items: center; }}
.topbar {{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap; }}
.topbar-right {{ margin-left:auto; text-align:right; min-width: 220px; }}
.topbar-right a {{ display:block; margin-bottom:4px; }}
.toggle-wrap {{ display:flex; align-items:center; padding: 0 2px; min-height: 38px; }}
.toggle-wrap label {{ position: static; font-size: 0.9rem; background: transparent; padding: 0; }}
.modal {{ position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: none; align-items: center; justify-content: center; z-index: 1000; }}
.modal.open {{ display:flex; }}
.modal-card {{ width: min(900px, 92vw); max-height: 92vh; overflow: auto; background: #fff; border-radius: 10px; padding: 12px; }}
.week-grid {{ display:grid; grid-template-columns: 56px repeat(7, 1fr); gap:8px; }}
.time-scale {{ border:1px solid #e5e7eb; border-radius:8px; background:#fff; overflow:hidden; }}
.time-scale .week-head {{ text-align:center; }}
.time-scale .time-lane {{ font-size:0.68rem; color:#6b7280; }}
.time-label {{ position:absolute; left:4px; transform: translateY(-50%); }}
.week-day {{ border:1px solid #e5e7eb; border-radius:8px; background:#fff; overflow:hidden; }}
.week-head {{ padding:6px; border-bottom:1px solid #e5e7eb; font-size:0.85rem; font-weight:600; }}
.all-day-list {{ min-height:24px; padding:4px; border-bottom:1px dashed #e5e7eb; font-size:0.75rem; }}
.time-lane {{ position:relative; height: 960px; background:
repeating-linear-gradient(to bottom, #f8fafc 0, #f8fafc 39px, #eef2f7 39px, #eef2f7 40px); }}
.week-event {{ position:absolute; background:#dcfce7; border:1px solid #86efac; border-radius:6px; font-size:0.72rem; padding:2px 4px; overflow:hidden; cursor:pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.08); }}
.week-event:hover {{ filter: brightness(0.97); transform: translateY(-1px); }}
.month-grid {{ display:grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap:8px; }}
.month-dow-grid {{ display:grid; grid-template-columns: repeat(7, minmax(0,1fr)); gap:8px; margin-bottom: 6px; }}
.month-dow {{ font-size: 0.78rem; font-weight: 700; color: #4b5563; text-align: center; }}
.month-cell {{ min-height:130px; border:1px solid #e5e7eb; border-radius:8px; background:#fff; padding:5px; }}
.month-cell.blank {{ background:#f3f4f6; }}
.day-num {{ font-size:0.82rem; font-weight:700; }}
.mini-evt {{ font-size:0.72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--accent, #34d399); background: color-mix(in srgb, var(--accent, #34d399) 12%, white); padding:2px 4px; margin-top:3px; cursor:pointer; border-radius:4px; }}
.mini-evt:hover {{ filter: brightness(0.97); }}
.year-grid {{ display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:10px; }}
.year-month {{ border:1px solid #e5e7eb; border-radius:8px; padding:6px; background:#fff; }}
.year-month h4 {{ margin: 3px 0 6px; font-size:0.9rem; }}
.year-days {{ display:grid; grid-template-columns: repeat(7, 1fr); gap:3px; }}
.year-day {{ font-size:0.7rem; border:1px solid #edf2f7; border-radius:4px; min-height:22px; text-align:center; line-height:22px; }}
.year-day.has-event {{ font-weight: 700; background: #ecfeff; border-color: #67e8f9; }}
.legend {{ display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }}
.legend .chip {{ border:1px solid #e5e7eb; border-left: 4px solid var(--accent,#34d399); border-radius: 6px; padding: 3px 8px; font-size: 0.75rem; background:#fff; }}
#occurrenceGrid .year-grid {{ grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 10px; align-items: start; }}
#occurrenceGrid .month-grid {{ gap: 4px; }}
#occurrenceGrid .month-cell {{ min-height: 24px; padding: 3px; }}
#occurrenceGrid .day-num {{ font-size: 0.76rem; line-height: 1.1; text-align: center; }}
#occurrenceGrid .month-cell.active {{ background: #ecfeff; border-color: #67e8f9; }}
#loginModal .field > label {{
position: static;
display: inline-block;
margin: 0 0 0.2rem;
font-size: 0.8rem;
background: transparent;
padding: 0;
color: #4b5563;
}}
#loginModal .field > input {{
padding: 0.6rem 0.65rem;
}}
</style>
</head>
<body>
{body_html}
</body>
</html>"""
def _handle_public_calendar_page(self) -> None:
page_html = """
<header><strong>Calendar Fixture</strong> <span class="muted">Public Calendar UI</span></header>
<main>
<div class="card">
<div class="topbar">
<div class="toolbar" id="topControls">
<div class="field"><label>View</label>
<select id="view">
<option value="list">List</option>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month" selected>Month</option>
<option value="year">Year</option>
</select>
</div>
<div class="field"><label>Date</label><input id="anchor" type="date" /></div>
<div id="futureOnlyWrap" class="toggle-wrap"><label><input id="futureOnly" type="checkbox" checked /> Future Only</label></div>
<button id="prevBtn" title="Previous">&#8592;</button>
<button id="todayBtn" title="Today">&#128197;</button>
<button id="nextBtn" title="Next">&#8594;</button>
<button id="newEventBtn">Create Event</button>
<button id="loginBtn">Login</button>
</div>
<div class="topbar-right">
<a href="__ICS__" target="_blank">ICS Link</a>
<a href="__CALDAV__" target="_blank">CalDAV</a>
<div id="status" class="muted"></div>
<div id="authState" class="muted"></div>
<button id="logoutBtn" style="display:none">Logout</button>
</div>
</div>
</div>
<div id="events"></div>
<div id="loginModal" class="modal">
<div class="modal-card">
<h3>Account Login</h3>
<div id="loginFeedback" class="muted"></div>
<div class="row">
<div class="field"><label>Email</label><input id="loginEmail" type="email" /></div>
<div class="field"><label>Password</label><input id="loginPassword" type="password" /></div>
</div>
<div class="toolbar">
<button id="doLoginBtn" disabled>Login</button>
<button id="closeLoginBtn">Close</button>
</div>
<div class="card">
<h4>Register</h4>
<div class="field"><label>Email</label><input id="regEmail" type="email" /></div>
<div class="field"><label>Password (8+ chars)</label><input id="regPw" type="password" /></div>
<div class="toolbar"><button id="regBtn" disabled>Register</button></div>
<div class="field"><label>Verify Token</label><input id="verifyToken" /></div>
<div class="toolbar"><button id="verifyBtn" disabled>Verify Email</button></div>
</div>
<div class="card">
<h4>Password Recovery</h4>
<div class="field"><label>Email</label><input id="forgotEmail" type="email" /></div>
<button id="forgotBtn" disabled>Send Reset Token</button>
<div class="field"><label>Reset Token</label><input id="resetToken" /></div>
<div class="field"><label>New Password</label><input id="resetPw" type="password" /></div>
<button id="resetBtn" disabled>Reset Password</button>
</div>
</div>
</div>
<div id="detailModal" class="modal">
<div class="modal-card">
<h3>Event Details</h3>
<div class="field"><label>Title</label><input id="detailTitle" readonly /></div>
<div class="field"><label>Category</label><input id="detailCategory" readonly /></div>
<div class="field"><label>Description</label><textarea id="detailDescription" rows="5" readonly></textarea></div>
<div class="toolbar"><button id="closeDetailBtn">Close</button></div>
</div>
</div>
<div id="modal" class="modal">
<div class="modal-card">
<h3 id="modalTitle">Event</h3>
<input id="eventId" type="hidden" />
<div class="field"><label>Title</label><input id="title" /></div>
<div class="row">
<div class="field"><label>Location</label><input id="location" /></div>
<div class="field"><label>Category</label><input id="category" /></div>
</div>
<div class="field"><label>Description</label><textarea id="description" rows="3"></textarea></div>
<div class="toggle-wrap"><label><input id="allDay" type="checkbox" /> All Day Event</label></div>
<div class="grid4">
<div class="field"><label>Start Date</label><input id="startDate" type="date" /></div>
<div class="field" id="startTimeWrap"><label>Start Time</label><input id="startTime" type="time" /></div>
<div class="field"><label>End Date</label><input id="endDate" type="date" /></div>
<div class="field" id="endTimeWrap"><label>End Time</label><input id="endTime" type="time" /></div>
</div>
<div class="row">
<div class="field"><label>Repeat Type</label>
<select id="repeatType">
<option value="none">none</option>
<option value="daily">daily</option>
<option value="weekly">weekly</option>
<option value="monthly">monthly</option>
<option value="yearly">yearly</option>
<option value="custom">custom</option>
</select>
</div>
<div class="field field-with-suffix" id="repeatIntervalWrap"><label>Every</label><input id="repeatInterval" type="number" min="1" value="1" /><span id="repeatIntervalText" class="input-suffix"></span></div>
</div>
<div class="row" id="modeUntilRow">
<div class="field"><label>Mode</label>
<select id="rangeMode">
<option value="none">none</option>
<option value="no_end">no_end</option>
<option value="count">count</option>
<option value="until">until</option>
</select>
</div>
<div class="field" id="repeatUntilWrap"><label>Repeat Until</label><input id="repeatUntil" type="date" /></div>
</div>
<div class="row" id="rangeWrap">
<div class="field" id="repeatCountWrap"><label>Repeat Count</label><input id="repeatCount" type="number" min="1" /></div>
</div>
<div class="row" id="monthlyNthWrap">
<div class="field"><label>Monthly Pattern</label>
<select id="monthlyNthMode">
<option value="">from start date</option>
<option value="day_of_month">nth day of month</option>
<option value="weekday_of_month">nth weekday of month</option>
</select>
</div>
<div class="field" id="nthDayWrap"><label>Day Number</label><input id="nthDay" type="number" min="1" max="31" /></div>
</div>
<div class="row" id="nthWeekdayWrap">
<div class="field"><label>Nth</label><select id="nthPos"><option value="1">1st</option><option value="2">2nd</option><option value="3">3rd</option><option value="4">4th</option><option value="5">5th</option><option value="-1">last</option></select></div>
<div class="field"><label>Weekday</label><select id="nthWeekday"><option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option></select></div>
</div>
<div class="toolbar">
<button id="saveCreateBtn">Create</button>
<button id="saveUpdateBtn">Update</button>
<button id="saveDeleteBtn">Delete</button>
<button id="toggleSingleOccurrenceBtn">Delete a Single Occurrence</button>
<button id="closeModalBtn">Close</button>
</div>
<div class="card" id="singleOccurrencePanel" style="display:none">
<h4>Delete Single Occurrence (Exception)</h4>
<div class="toolbar">
<button id="occPrevBtn">&#8592;</button>
<button id="occNextBtn">&#8594;</button>
</div>
<div id="occurrenceGrid" class="month-grid"></div>
<input id="occKey" type="hidden" />
<div class="field"><label>Occurrence</label><input id="occKeyDisplay" readonly placeholder="Select an occurrence from the calendar above" /></div>
<button id="deleteOccurrenceBtn">Delete Occurrence</button>
</div>
</div>
</div>
</main>
<script>
const statusEl = document.getElementById('status');
const authStateEl = document.getElementById('authState');
const loginFeedbackEl = document.getElementById('loginFeedback');
const eventsEl = document.getElementById('events');
const viewEl = document.getElementById('view');
const anchorEl = document.getElementById('anchor');
const futureOnlyWrapEl = document.getElementById('futureOnlyWrap');
const futureOnlyEl = document.getElementById('futureOnly');
const modalEl = document.getElementById('modal');
const loginModalEl = document.getElementById('loginModal');
const detailModalEl = document.getElementById('detailModal');
const loginBtnEl = document.getElementById('loginBtn');
const logoutBtnEl = document.getElementById('logoutBtn');
const newEventBtnEl = document.getElementById('newEventBtn');
const ROOT = __ROOT__;
let session = { email:'', password:'', user:null, canWrite:false };
viewEl.value = 'month';
function pad2(n){ return String(n).padStart(2,'0'); }
function ymdLocal(d){ return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
function parseYmd(s){ const p=(s||'').split('-'); if(p.length!==3) return new Date(); return new Date(Number(p[0]), Number(p[1])-1, Number(p[2])); }
anchorEl.value = ymdLocal(new Date());
function rootPath(path){ return ROOT + path; }
function apiUrl(path) { return rootPath(path); }
function authHeaders(){ return session.email && session.password ? { Authorization: 'Basic ' + btoa(session.email + ':' + session.password) } : {}; }
function isEmail(v){ return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test((v||'').trim()); }
function showLoginFeedback(msg, ok){
loginFeedbackEl.textContent = msg || '';
loginFeedbackEl.className = ok ? 'ok' : (msg ? 'err' : 'muted');
}
function updateAuthButtonStates(){
const loginEmail = document.getElementById('loginEmail').value || '';
const loginPw = document.getElementById('loginPassword').value || '';
const regEmail = document.getElementById('regEmail').value || '';
const regPw = document.getElementById('regPw').value || '';
const verifyToken = document.getElementById('verifyToken').value || '';
const forgotEmail = document.getElementById('forgotEmail').value || '';
const resetToken = document.getElementById('resetToken').value || '';
const resetPw = document.getElementById('resetPw').value || '';
document.getElementById('doLoginBtn').disabled = !(isEmail(loginEmail) && loginPw.length >= 8);
document.getElementById('regBtn').disabled = !(isEmail(regEmail) && regPw.length >= 8);
document.getElementById('verifyBtn').disabled = !verifyToken.trim();
document.getElementById('forgotBtn').disabled = !isEmail(forgotEmail);
document.getElementById('resetBtn').disabled = !(resetToken.trim() && resetPw.length >= 8);
}
function refreshAuthUi(){
const logged = !!session.user;
loginBtnEl.style.display = logged ? 'none' : '';
logoutBtnEl.style.display = logged ? '' : 'none';
newEventBtnEl.style.display = session.canWrite ? '' : 'none';
authStateEl.textContent = logged
? `Logged in as ${session.user.email}`
: 'Public mode: read-only';
updateEventButtons();
}
function splitIsoToInputs(iso) { if (!iso) return {d:'',t:''}; if (!iso.includes('T')) return {d:iso.slice(0,10),t:''}; return {d:iso.slice(0,10),t:iso.slice(11,16)}; }
function toIso(d,t,allDay){ if(!d) return ''; if(allDay) return d; return `${d}T${(t||'00:00')}:00+01:00`; }
function formatOccurrenceKey(iso){
if (!iso) return '';
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return iso;
return dt.toLocaleString('en-GB', { year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function applyAllDayVisibility(){
const allDay = !!document.getElementById('allDay').checked;
document.getElementById('startTimeWrap').style.display = allDay ? 'none' : '';
document.getElementById('endTimeWrap').style.display = allDay ? 'none' : '';
}
let occCursor = new Date();
function everyText(){
const n = Number(document.getElementById('repeatInterval').value || 1);
const rt = document.getElementById('repeatType').value;
const unit = rt === 'daily' ? 'day' : rt === 'weekly' ? 'week' : rt === 'monthly' ? 'month' : rt === 'yearly' ? 'year' : rt === 'custom' ? 'week' : '';
document.getElementById('repeatIntervalText').textContent = unit ? `${unit}${n===1?'':'s'}` : '';
}
function applyRecurrenceVisibility(){ const rt=document.getElementById('repeatType').value; const rm=document.getElementById('rangeMode').value; const show=rt!=='none';
document.getElementById('repeatIntervalWrap').style.display=show?'':'none';
document.getElementById('modeUntilRow').style.display=show?'':'none';
document.getElementById('rangeWrap').style.display=(show&&rm==='count')?'':'none';
document.getElementById('repeatCountWrap').style.display=(show&&rm==='count')?'':'none';
document.getElementById('repeatUntilWrap').style.display=(show&&rm==='until')?'':'none';
document.getElementById('monthlyNthWrap').style.display=(rt==='monthly')?'':'none';
const m = document.getElementById('monthlyNthMode').value;
document.getElementById('nthDayWrap').style.display=(rt==='monthly' && m==='day_of_month')?'':'none';
document.getElementById('nthWeekdayWrap').style.display=(rt==='monthly' && m==='weekday_of_month')?'':'none';
document.getElementById('toggleSingleOccurrenceBtn').style.display=show?'':'none';
if (!show) document.getElementById('singleOccurrencePanel').style.display='none';
everyText();
updateEventButtons();
}
function openModal(mode,evt){ if(!evt) evt={}; const sd=splitIsoToInputs(evt.start_datetime), ed=splitIsoToInputs(evt.end_datetime);
if (!session.canWrite) return;
document.getElementById('modalTitle').textContent=mode==='create'?'Create Event':'Edit Event';
document.getElementById('eventId').value=evt.id||''; document.getElementById('title').value=evt.title||''; document.getElementById('location').value=evt.location||'';
document.getElementById('category').value=evt.category||''; document.getElementById('description').value=evt.description||'';
document.getElementById('allDay').checked=!!evt.all_day_event; document.getElementById('startDate').value=sd.d||''; document.getElementById('startTime').value=sd.t||'';
document.getElementById('endDate').value=ed.d||''; document.getElementById('endTime').value=ed.t||''; document.getElementById('repeatType').value=evt.repeat_type||'none';
document.getElementById('repeatInterval').value=evt.repeat_interval||1; document.getElementById('rangeMode').value=evt.repeat_range_mode||'none';
document.getElementById('repeatCount').value=evt.repeat_count||''; document.getElementById('repeatUntil').value=evt.repeat_until||'';
document.getElementById('monthlyNthMode').value=evt.repeat_nth_mode||'';
document.getElementById('nthDay').value=evt.repeat_nth_day||'';
document.getElementById('nthPos').value=evt.repeat_nth_pos||'1';
document.getElementById('nthWeekday').value=evt.repeat_nth_weekday===null||evt.repeat_nth_weekday===undefined?'0':String(evt.repeat_nth_weekday);
document.getElementById('singleOccurrencePanel').style.display='none';
document.getElementById('occKey').value = '';
document.getElementById('occKeyDisplay').value = '';
occCursor = new Date((evt.start_datetime||new Date().toISOString()).slice(0,10) + 'T00:00:00');
document.getElementById('toggleSingleOccurrenceBtn').style.display = mode==='edit' ? '' : 'none';
document.getElementById('saveCreateBtn').style.display=mode==='create'?'':'none'; document.getElementById('saveUpdateBtn').style.display=mode==='edit'?'':'none';
document.getElementById('saveDeleteBtn').style.display=mode==='edit'?'':'none'; applyRecurrenceVisibility(); modalEl.classList.add('open');
applyAllDayVisibility();
updateEventButtons();
}
function closeModal(){ modalEl.classList.remove('open'); }
function openDetail(item){
document.getElementById('detailTitle').value = item?.title || '';
document.getElementById('detailCategory').value = item?.category || '';
document.getElementById('detailDescription').value = item?.description || '';
detailModalEl.classList.add('open');
}
function closeDetail(){ detailModalEl.classList.remove('open'); }
function updateEventButtons(){
const titleOk = !!(document.getElementById('title').value || '').trim();
const startDate = (document.getElementById('startDate').value || '').trim();
const endDateRaw = (document.getElementById('endDate').value || '').trim();
const endDate = endDateRaw || startDate;
const allDay = !!document.getElementById('allDay').checked;
const startTime = (document.getElementById('startTime').value || '').trim();
const endTime = (document.getElementById('endTime').value || '').trim();
let validDateTime = false;
if (startDate && endDate && endDate >= startDate){
if (allDay){
validDateTime = true;
} else if (startTime && endTime){
validDateTime = endDate > startDate || endTime >= startTime;
}
}
const hasCore = titleOk && validDateTime;
const hasId = !!(document.getElementById('eventId').value || '').trim();
document.getElementById('saveCreateBtn').disabled = !(session.canWrite && hasCore && !hasId);
document.getElementById('saveUpdateBtn').disabled = !(session.canWrite && hasCore && hasId);
document.getElementById('saveDeleteBtn').disabled = !(session.canWrite && hasId);
document.getElementById('toggleSingleOccurrenceBtn').disabled = !(session.canWrite && hasId && document.getElementById('repeatType').value !== 'none');
document.getElementById('deleteOccurrenceBtn').disabled = !(session.canWrite && hasId && (document.getElementById('occKey').value || '').trim());
}
function eventPayload(){ const ad=document.getElementById('allDay').checked; return {
title:document.getElementById('title').value, location:document.getElementById('location').value, category:document.getElementById('category').value,
description:document.getElementById('description').value, all_day_event:ad,
start_datetime:toIso(document.getElementById('startDate').value,document.getElementById('startTime').value,ad),
end_datetime:toIso((document.getElementById('endDate').value || document.getElementById('startDate').value),document.getElementById('endTime').value,ad),
repeat_type:document.getElementById('repeatType').value, repeat_interval:Number(document.getElementById('repeatInterval').value||1),
repeat_nth_mode:document.getElementById('monthlyNthMode').value,
repeat_nth_day:document.getElementById('nthDay').value?Number(document.getElementById('nthDay').value):null,
repeat_nth_pos:document.getElementById('nthPos').value?Number(document.getElementById('nthPos').value):null,
repeat_nth_weekday:document.getElementById('nthWeekday').value === '' ? null : Number(document.getElementById('nthWeekday').value),
repeat_range_mode:document.getElementById('rangeMode').value, repeat_count:document.getElementById('repeatCount').value?Number(document.getElementById('repeatCount').value):null,
repeat_until:document.getElementById('repeatUntil').value||null
};}
async function loadOccurrenceChooser(){
const id = document.getElementById('eventId').value;
if (!id) return;
const from = new Date(occCursor.getFullYear(), occCursor.getMonth(), 1);
const fromS = ymdLocal(from);
const r = await fetch(apiUrl('/wp-json/calendar/v1/events/' + id + '/occurrences?from=' + fromS + '&months=3'), {headers:authHeaders()});
const p = await r.json();
const list = p.data || [];
const dayToKey = new Map();
list.forEach(x => {
const d = (x.occurrence_start || '').slice(0,10);
if (d && !dayToKey.has(d)) dayToKey.set(d, x.occurrence_start);
});
const cells = [];
for(let m=0;m<3;m++){
const d = new Date(from.getFullYear(), from.getMonth()+m, 1);
const y=d.getFullYear(), mo=d.getMonth();
const first=new Date(y,mo,1), off=(first.getDay()+6)%7, dim=new Date(y,mo+1,0).getDate();
let monthCells=''; for(let i=0;i<off;i++) monthCells += '<div class="month-cell blank"></div>';
for(let day=1; day<=dim; day++){
const dt=new Date(y,mo,day); const k=ymdLocal(dt); const has=dayToKey.has(k);
monthCells += `<div class="month-cell ${has?'active':'blank'}"><div class="day-num" data-occ-date="${k}" style="cursor:${has?'pointer':'default'}">${day}</div></div>`;
}
cells.push(`<div class="year-month"><h4>${d.toLocaleString('en-GB',{month:'short',year:'numeric'})}</h4><div class="month-grid">${monthCells}</div></div>`);
}
document.getElementById('occurrenceGrid').innerHTML = `<div class="year-grid">${cells.join('')}</div>`;
document.querySelectorAll('[data-occ-date]').forEach(el=>el.addEventListener('click',()=>{
const k=el.getAttribute('data-occ-date'); if(!dayToKey.has(k)) return;
const iso = dayToKey.get(k) || '';
document.getElementById('occKey').value = iso;
document.getElementById('occKeyDisplay').value = formatOccurrenceKey(iso);
updateEventButtons();
}));
}
function startMinutes(iso){ if(!iso||!iso.includes('T')) return 0; return Number(iso.slice(11,13))*60+Number(iso.slice(14,16)); }
function categoryColor(category){
const base = (category || 'general').toLowerCase();
let h = 0; for (let i = 0; i < base.length; i++) h = (h * 31 + base.charCodeAt(i)) % 360;
return `hsl(${h} 70% 45%)`;
}
function eventChip(e, cls='mini-evt'){ return `<div class="${cls}" data-edit="${e.event_id}" style="--accent:${categoryColor(e.category)}">${e.title}</div>`; }
function collectLegend(items){
const map = new Map();
items.forEach(e => { const c = e.category || 'Uncategorised'; if (!map.has(c)) map.set(c, categoryColor(c)); });
return `<div class="legend">${Array.from(map.entries()).map(([k,v]) => `<div class="chip" style="--accent:${v}">${k}</div>`).join('')}</div>`;
}
function renderList(items){
const fmt = (e) => {
const sd = (e.occurrence_start||'').slice(0,10);
const st = e.all_day_event ? 'All-day' : (e.occurrence_start||'').slice(11,16);
const et = e.all_day_event ? 'All-day' : (e.occurrence_end||'').slice(11,16);
const desc = (e.description || '').trim() || e.title || '';
return `${sd} ${st}${et} ${desc}`;
};
eventsEl.innerHTML=(items.map(e=>`<div class="card">${fmt(e)}</div>`).join('')||'<div class="card">No events in this window.</div>');
}
function layoutTimedEvents(dayItems){
const events = dayItems.slice().sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
let i = 0; const out = [];
while (i < events.length){
let clusterEnd = startMinutes(events[i].occurrence_end);
const cluster = [events[i]];
let j = i + 1;
while (j < events.length && startMinutes(events[j].occurrence_start) < clusterEnd){
cluster.push(events[j]); clusterEnd = Math.max(clusterEnd, startMinutes(events[j].occurrence_end)); j++;
}
const active = []; let maxCols = 0;
const placed = cluster.map(e => {
const start = startMinutes(e.occurrence_start), end = startMinutes(e.occurrence_end);
for (let k = active.length - 1; k >= 0; k--) if (active[k].end <= start) active.splice(k,1);
const used = new Set(active.map(a => a.col));
let col = 0; while (used.has(col)) col++;
active.push({ end, col }); maxCols = Math.max(maxCols, col + 1);
return { e, start, end, col };
});
placed.forEach(p => out.push({ ...p, span: maxCols }));
i = j;
}
return out;
}
function renderWeek(items,anchor){
const d=new Date(anchor+'T00:00:00'); const diff=(d.getDay()+6)%7; d.setDate(d.getDate()-diff);
const days=Array.from({length:7},(_,i)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()+i)); const by=Object.fromEntries(days.map(x=>[ymdLocal(x),[]]));
items.forEach(e=>{const k=(e.occurrence_start||'').slice(0,10); if(by[k]) by[k].push(e);});
const scaleLabels = Array.from({length:24}, (_,h)=>`<div class="time-label" style="top:${h*40}px">${String(h).padStart(2,'0')}:00</div>`).join('');
const cols=days.map(dt=>{
const k=ymdLocal(dt); const day=(by[k]||[]).sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
const ad=day.filter(x=>x.all_day_event), td=day.filter(x=>!x.all_day_event);
const blocks=layoutTimedEvents(td).map(p=>{
const top=Math.max(0,p.start/1.5), h=Math.max(24,(p.end-p.start)/1.5);
const widthPct=100/p.span; const left=`calc(${p.col*widthPct}% + 2px)`; const width=`calc(${widthPct}% - 4px)`;
const st = (p.e.occurrence_start||'').slice(11,16);
return `<div class="week-event" data-edit="${p.e.event_id}" style="top:${top}px;height:${h}px;left:${left};width:${width};--accent:${categoryColor(p.e.category)};border-color:var(--accent);background:color-mix(in srgb, var(--accent) 18%, white);">${st} ${p.e.title}</div>`;
}).join('');
return `<div class="week-day"><div class="week-head">${dt.toDateString().slice(0,10)}</div><div class="all-day-list">${ad.map(e=>eventChip(e)).join('')}</div><div class="time-lane">${blocks}</div></div>`;
}).join('');
const scaleCol = `<div class="time-scale"><div class="week-head">Time</div><div class="time-lane">${scaleLabels}</div></div>`;
eventsEl.innerHTML=`<div class="week-grid">${scaleCol}${cols}</div>${collectLegend(items)}`;
}
function renderMonth(items,anchor){
const b=new Date(anchor+'T00:00:00'); const y=b.getFullYear(),m=b.getMonth(); const first=new Date(y,m,1); const off=(first.getDay()+6)%7; const dim=new Date(y,m+1,0).getDate();
const by={}; items.forEach(e=>{const k=(e.occurrence_start||'').slice(0,10); (by[k]=by[k]||[]).push(e);});
const dow = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const headers = dow.map(d=>`<div class="month-dow">${d}</div>`).join('');
let cells=''; for(let i=0;i<off;i++) cells+='<div class="month-cell blank"></div>';
for(let d=1; d<=dim; d++){
const dt=new Date(y,m,d), k=ymdLocal(dt);
const evs=(by[k]||[]).sort((a,b)=>a.occurrence_start.localeCompare(b.occurrence_start));
cells+=`<div class="month-cell"><div class="day-num">${d}</div>${evs.map(e=>{ const t=e.all_day_event?'All-day':(e.occurrence_start||'').slice(11,16); return eventChip({...e, title:`${t} ${e.title}`}); }).join('')}</div>`;
}
eventsEl.innerHTML=`<div class="month-dow-grid">${headers}</div><div class="month-grid">${cells}</div>${collectLegend(items)}`;
}
function renderYear(items,anchor){
const y=new Date(anchor+'T00:00:00').getFullYear(); const set=new Set(items.map(e=>(e.occurrence_start||'').slice(0,10))); const n=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const blocks=Array.from({length:12},(_,m)=>{const f=new Date(y,m,1),off=(f.getDay()+6)%7,dim=new Date(y,m+1,0).getDate(); let cells=''; for(let i=0;i<off;i++) cells+='<div class="year-day"></div>';
for(let d=1; d<=dim; d++){ const k=ymdLocal(new Date(y,m,d)); cells+=`<div class="${set.has(k)?'year-day has-event':'year-day'}" data-year-date="${k}">${d}</div>`; }
return `<div class="year-month"><h4>${n[m]}</h4><div class="year-days">${cells}</div></div>`; }).join('');
eventsEl.innerHTML=`<div class="year-grid">${blocks}</div>`;
}
function startOfViewPeriod(d, view){
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (view === 'week'){ const diff = (x.getDay()+6)%7; x.setDate(x.getDate()-diff); return x; }
if (view === 'month'){ return new Date(x.getFullYear(), x.getMonth(), 1); }
if (view === 'year'){ return new Date(x.getFullYear(), 0, 1); }
return x;
}
async function loadEvents(){ statusEl.textContent='Loading...'; const qs=new URLSearchParams({view:viewEl.value,date:anchorEl.value}); const resp=await fetch(rootPath('/wp-json/calendar/v1/public/events?'+qs.toString())); const payload=await resp.json();
if(!resp.ok){ statusEl.textContent=payload?.error?.message||'Error'; statusEl.className='err'; return; } let items=payload.data||[];
futureOnlyWrapEl.style.display = viewEl.value === 'list' ? '' : 'none';
if(viewEl.value==='list' && futureOnlyEl.checked){ const td=ymdLocal(new Date()); items=items.filter(i=>(i.occurrence_start||'').slice(0,10)>=td); }
statusEl.textContent=`Loaded ${items.length} item(s)`; statusEl.className='ok';
if(viewEl.value==='week') renderWeek(items,anchorEl.value); else if(viewEl.value==='month') renderMonth(items,anchorEl.value); else if(viewEl.value==='year') renderYear(items,anchorEl.value); else renderList(items);
eventsEl.querySelectorAll('[data-edit]').forEach(el=>el.addEventListener('click',async()=>{
const id=el.getAttribute('data-edit');
if (session.canWrite) {
const r=await fetch(apiUrl('/wp-json/calendar/v1/events/'+id),{headers:authHeaders()}); const p=await r.json(); if(p?.data) openModal('edit',p.data);
return;
}
const hit = items.find(i => String(i.event_id) === String(id));
if (hit) openDetail(hit);
}));
eventsEl.querySelectorAll('[data-year-date]').forEach(el=>el.addEventListener('click',()=>{ viewEl.value='week'; anchorEl.value=el.getAttribute('data-year-date'); loadEvents(); }));
}
async function apiWrite(path,method,bodyObj){ const resp=await fetch(apiUrl(path),{method,headers:{'Content-Type':'application/json',...authHeaders()},body:bodyObj?JSON.stringify(bodyObj):undefined}); statusEl.textContent=`${method} ${path} -> ${resp.status}`; statusEl.className=resp.ok?'ok':'err'; return resp; }
newEventBtnEl.addEventListener('click',()=>openModal('create',{}));
document.getElementById('closeModalBtn').addEventListener('click',closeModal);
document.getElementById('saveCreateBtn').addEventListener('click',async()=>{await apiWrite('/wp-json/calendar/v1/events','POST',eventPayload()); closeModal(); loadEvents();});
document.getElementById('saveUpdateBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; if(!id) return; await apiWrite('/wp-json/calendar/v1/events/'+id,'PATCH',eventPayload()); closeModal(); loadEvents();});
document.getElementById('saveDeleteBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; if(!id) return; await apiWrite('/wp-json/calendar/v1/events/'+id,'DELETE'); closeModal(); loadEvents();});
document.getElementById('toggleSingleOccurrenceBtn').addEventListener('click',async()=>{ const p=document.getElementById('singleOccurrencePanel'); p.style.display = p.style.display==='none'?'':'none'; if(p.style.display!=='none') await loadOccurrenceChooser(); });
document.getElementById('occPrevBtn').addEventListener('click',async()=>{ occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth()-1, 1); await loadOccurrenceChooser(); });
document.getElementById('occNextBtn').addEventListener('click',async()=>{ occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth()+1, 1); await loadOccurrenceChooser(); });
document.getElementById('deleteOccurrenceBtn').addEventListener('click',async()=>{const id=document.getElementById('eventId').value; const key=encodeURIComponent(document.getElementById('occKey').value); if(!id||!key) return; await apiWrite('/wp-json/calendar/v1/events/'+id+'/occurrences/'+key,'DELETE'); await loadOccurrenceChooser(); loadEvents();});
document.getElementById('repeatType').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('rangeMode').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('monthlyNthMode').addEventListener('change',applyRecurrenceVisibility);
document.getElementById('repeatInterval').addEventListener('input',everyText);
document.getElementById('allDay').addEventListener('change', ()=>{{ applyAllDayVisibility(); updateEventButtons(); }});
['title','startDate','endDate','startTime','endTime','repeatType','occKey'].forEach(id=>document.getElementById(id).addEventListener('input',updateEventButtons));
function shiftAnchor(dir){
const d = parseYmd(anchorEl.value);
const v = viewEl.value;
const s = startOfViewPeriod(d, v);
if (v === 'day' || v === 'list') s.setDate(s.getDate() + dir);
else if (v === 'week') s.setDate(s.getDate() + (7*dir));
else if (v === 'month') s.setMonth(s.getMonth() + dir);
else if (v === 'year') s.setFullYear(s.getFullYear() + dir);
anchorEl.value = ymdLocal(startOfViewPeriod(s, v));
}
document.getElementById('prevBtn').addEventListener('click',()=>{ shiftAnchor(-1); loadEvents(); });
document.getElementById('nextBtn').addEventListener('click',()=>{ shiftAnchor(1); loadEvents(); });
document.getElementById('todayBtn').addEventListener('click',()=>{ const t = startOfViewPeriod(new Date(), viewEl.value); anchorEl.value = ymdLocal(t); loadEvents(); });
loginBtnEl.addEventListener('click', ()=> loginModalEl.classList.add('open'));
document.getElementById('closeLoginBtn').addEventListener('click', ()=>{ loginModalEl.classList.remove('open'); showLoginFeedback('', true); });
document.getElementById('closeDetailBtn').addEventListener('click', closeDetail);
logoutBtnEl.addEventListener('click', ()=>{ session = { email:'', password:'', user:null, canWrite:false }; refreshAuthUi(); closeModal(); loadEvents(); });
document.getElementById('doLoginBtn').addEventListener('click', async ()=>{
const email = (document.getElementById('loginEmail').value || '').trim().toLowerCase();
const password = document.getElementById('loginPassword').value || '';
const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/me'), { headers: { Authorization: 'Basic ' + btoa(email + ':' + password) } });
const payload = await resp.json();
if (!resp.ok || !payload?.data){
const msg = 'Login failure';
statusEl.textContent = msg; statusEl.className='err';
showLoginFeedback(msg, false);
return;
}
session.email = email;
session.password = password;
session.user = payload.data;
session.canWrite = payload.data.account_status === 'active';
refreshAuthUi();
showLoginFeedback('Login succeeded.', true);
loginModalEl.classList.remove('open');
loadEvents();
});
document.getElementById('regBtn').addEventListener('click', async ()=>{
const body = { email: (document.getElementById('regEmail').value || '').trim().toLowerCase(), password: document.getElementById('regPw').value || '' };
const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/register'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const payload = await resp.json();
const msg = payload?.error?.message || 'Registered. Check your email for a verification link or token.';
statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
showLoginFeedback(msg, resp.ok);
});
document.getElementById('verifyBtn').addEventListener('click', async ()=>{
const body = { token: document.getElementById('verifyToken').value || '' };
const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/verify'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const payload = await resp.json();
const msg = resp.ok
? 'An admin will review your request and notify you if approved.'
: (payload?.error?.message || 'Verify failed');
statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
showLoginFeedback(msg, resp.ok);
});
document.getElementById('forgotBtn').addEventListener('click', async ()=>{
const body = { email: (document.getElementById('forgotEmail').value || '').trim().toLowerCase() };
const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/forgot-password'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const payload = await resp.json();
const msg = payload?.error?.message || 'If the account exists, a reset token has been sent by email.';
statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
showLoginFeedback(msg, resp.ok);
});
document.getElementById('resetBtn').addEventListener('click', async ()=>{
const body = { token: document.getElementById('resetToken').value || '', new_password: document.getElementById('resetPw').value || '' };
const resp = await fetch(apiUrl('/wp-json/calendar/v1/users/reset-password'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const payload = await resp.json();
const msg = payload?.error?.message || 'Password reset complete';
statusEl.textContent = msg; statusEl.className = resp.ok ? 'ok' : 'err';
showLoginFeedback(msg, resp.ok);
});
viewEl.addEventListener('change',()=>{ const d = parseYmd(anchorEl.value); const s = startOfViewPeriod(d, viewEl.value); anchorEl.value = ymdLocal(s); loadEvents(); });
anchorEl.addEventListener('change',()=>{ const d = parseYmd(anchorEl.value); const s = startOfViewPeriod(d, viewEl.value); anchorEl.value = ymdLocal(s); loadEvents(); });
futureOnlyEl.addEventListener('change',loadEvents);
['loginEmail','loginPassword','regEmail','regPw','verifyToken','forgotEmail','resetToken','resetPw'].forEach(id=>{ const el=document.getElementById(id); el.addEventListener('input', updateAuthButtonStates); });
updateAuthButtonStates();
showLoginFeedback('', true);
refreshAuthUi();
applyAllDayVisibility();
updateEventButtons();
loadEvents();
</script>
"""
page_html = page_html.replace("__ICS__", self._with_slug("/calendar.ics"))
page_html = page_html.replace("__CALDAV__", self._with_slug("/caldav/"))
page_html = page_html.replace("__ROOT__", json.dumps(self._slug_prefix()))
html = self._base_page("Calendar", page_html)
self._write_text(200, html, "text/html; charset=utf-8")
def _handle_sidebar_shortcode_page(self) -> None:
page_html = """
<header><strong>Calendar Fixture</strong> <span class="muted">Sidebar Shortcode Preview</span></header>
<main>
<div class="card">
<div class="muted">Shortcode: <code>[calendar_sidebar_upcoming]</code></div>
<div class="muted">Window: next 14 days</div>
</div>
<div id="sidebarList"></div>
</main>
<script>
const ROOT = __ROOT__;
function rootPath(path){ return ROOT + path; }
function fmt(e){
const sd = (e.occurrence_start||'').slice(0,10);
const st = e.all_day_event ? 'All-day' : (e.occurrence_start||'').slice(11,16);
const et = e.all_day_event ? 'All-day' : (e.occurrence_end||'').slice(11,16);
const desc = (e.description || '').trim() || e.title || '';
return `${sd} ${st}${et} ${desc}`;
}
async function load(){
const el = document.getElementById('sidebarList');
const resp = await fetch(rootPath('/wp-json/calendar/v1/public/sidebar-events'));
const payload = await resp.json();
if(!resp.ok){
el.innerHTML = `<div class="card err">${payload?.error?.message || 'Error'}</div>`;
return;
}
const items = payload?.data || [];
el.innerHTML = items.length
? items.map(e => `<div class="card">${fmt(e)}</div>`).join('')
: '<div class="card">No events in this window.</div>';
}
load();
</script>
"""
page_html = page_html.replace("__ROOT__", json.dumps(self._slug_prefix()))
html = self._base_page("Calendar Sidebar Shortcode", page_html)
self._write_text(200, html, "text/html; charset=utf-8")
def do_POST(self) -> None:
self._trace_start()
try:
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if path.startswith(API_BASE):
self._handle_api_post(path)
return
self._not_found()
finally:
self._trace_finish()
def do_PUT(self) -> None:
self._trace_start()
try:
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if path.startswith(API_BASE):
self._handle_api_put(path)
return
if path.startswith("/caldav/"):
self._handle_caldav_put(path)
return
self._not_found()
finally:
self._trace_finish()
def do_PATCH(self) -> None:
self._trace_start()
try:
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if path.startswith(API_BASE):
self._handle_api_patch(path)
return
self._not_found()
finally:
self._trace_finish()
def do_DELETE(self) -> None:
self._trace_start()
try:
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if path.startswith(API_BASE):
self._handle_api_delete(path)
return
if path.startswith("/caldav/"):
self._handle_caldav_delete(path)
return
self._not_found()
finally:
self._trace_finish()
def do_PROPFIND(self) -> None:
self._trace_start()
try:
self._read_request_body_bytes()
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if not path.startswith("/caldav/"):
self._not_found()
return
auth = self._authenticate_caldav_user()
if not auth:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
self.end_headers()
return
prefix = self._slug_prefix()
caldav_root = f"{prefix}/caldav/"
principals_root = f"{prefix}/caldav/principals/"
principal_href = f"{prefix}/caldav/principals/user/"
calendars_root = f"{prefix}/caldav/calendars/"
public_calendar = f"{prefix}/caldav/calendars/public/"
conn = ensure_db()
caldav_calendar_name = get_setting(conn, "caldav_calendar_name", "Public Calendar")
conn.close()
def response_block(href: str, prop_xml: str) -> str:
return textwrap.dedent(
f"""\
<D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
{prop_xml}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
"""
).strip()
root_props = (
"<D:resourcetype><D:collection/></D:resourcetype>"
f"<D:current-user-principal><D:href>{principal_href}</D:href></D:current-user-principal>"
)
principal_props = (
"<D:resourcetype><D:collection/><D:principal/></D:resourcetype>"
f"<C:calendar-home-set><D:href>{calendars_root}</D:href></C:calendar-home-set>"
)
calendar_home_props = "<D:resourcetype><D:collection/></D:resourcetype>"
public_calendar_props = (
"<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"
f"<D:displayname>{_xml_escape(caldav_calendar_name)}</D:displayname>"
"<C:supported-calendar-component-set>"
"<C:comp name=\"VEVENT\"/>"
"</C:supported-calendar-component-set>"
)
responses: List[str] = []
canonical = path if path.endswith("/") else f"{path}/"
if canonical in {"/caldav/", "/caldav"}:
responses.append(response_block(caldav_root, root_props))
if self.headers.get("Depth", "0") != "0":
responses.append(response_block(principal_href, principal_props))
responses.append(response_block(calendars_root, calendar_home_props))
responses.append(response_block(public_calendar, public_calendar_props))
elif canonical == "/caldav/principals/":
responses.append(response_block(principals_root, "<D:resourcetype><D:collection/></D:resourcetype>"))
if self.headers.get("Depth", "0") != "0":
responses.append(response_block(principal_href, principal_props))
elif canonical == "/caldav/principals/user/":
responses.append(response_block(principal_href, principal_props))
elif canonical == "/caldav/calendars/":
responses.append(response_block(calendars_root, calendar_home_props))
if self.headers.get("Depth", "0") != "0":
responses.append(response_block(public_calendar, public_calendar_props))
elif canonical == "/caldav/calendars/public/":
responses.append(response_block(public_calendar, public_calendar_props))
if self.headers.get("Depth", "0") != "0":
conn = ensure_db()
rows = conn.execute(
"SELECT id, etag, caldav_resource AS resource FROM events WHERE calendar_id = ? AND caldav_resource IS NOT NULL ORDER BY id ASC",
(SHARED_CALENDAR_ID,),
).fetchall()
conn.close()
for row in rows:
href = f"{prefix}/caldav/calendars/public/{row['resource']}"
item_props = (
"<D:resourcetype/>"
"<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>"
f"<D:getetag>{row['etag']}</D:getetag>"
)
responses.append(response_block(href, item_props))
else:
self._not_found()
return
body = (
'<?xml version="1.0" encoding="utf-8" ?>\n'
'<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
+ "\n".join(responses)
+ "\n</D:multistatus>\n"
)
self.send_response(207)
self.send_header("Content-Type", "application/xml; charset=utf-8")
self.send_header("Content-Length", str(len(body.encode("utf-8"))))
self.end_headers()
self.wfile.write(body.encode("utf-8"))
finally:
self._trace_finish()
def do_REPORT(self) -> None:
self._trace_start()
try:
request_body = self._read_request_body_bytes().decode("utf-8", errors="replace")
path = self._strip_slug(urlparse(self.path).path)
if path is None:
self._not_found()
return
if not path.startswith("/caldav/"):
self._not_found()
return
auth = self._authenticate_caldav_user()
if not auth:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
self.end_headers()
return
conn = ensure_db()
all_rows = conn.execute(
"SELECT * FROM events WHERE calendar_id = ? AND caldav_resource IS NOT NULL ORDER BY id ASC", (SHARED_CALENDAR_ID,)
).fetchall()
conn.close()
rows = list(all_rows)
requested_hrefs = re.findall(r"<D:href>\s*([^<]+)\s*</D:href>", request_body, re.IGNORECASE)
if "calendar-multiget" in request_body.lower() and requested_hrefs:
wanted_resources: set[str] = set()
for href in requested_hrefs:
href_path = urlparse(href.strip()).path
stripped = self._strip_slug(href_path)
if stripped is None:
stripped = href_path
filename = _caldav_filename_from_path(stripped)
if filename:
wanted_resources.add(filename)
if wanted_resources:
rows = [r for r in all_rows if str(r["caldav_resource"]) in wanted_resources]
else:
rows = []
prefix = self._slug_prefix()
responses = []
for row in rows:
evt = event_to_dict(row)
href = f"{prefix}/caldav/calendars/public/{row['caldav_resource']}"
ics = self._event_as_ics(evt)
responses.append(
textwrap.dedent(
f"""\
<D:response>
<D:href>{href}</D:href>
<D:propstat>
<D:prop>
<D:getetag>{row["etag"]}</D:getetag>
<C:calendar-data>{_xml_escape(ics)}</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
"""
).strip()
)
body = (
'<?xml version="1.0" encoding="utf-8" ?>\n'
'<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
+ "\n".join(responses)
+ "\n</D:multistatus>\n"
)
self.send_response(207)
self.send_header("Content-Type", "application/xml; charset=utf-8")
self.send_header("Content-Length", str(len(body.encode("utf-8"))))
self.end_headers()
self.wfile.write(body.encode("utf-8"))
finally:
self._trace_finish()
def _handle_admin_page(self, parsed) -> None:
auth = self._authenticate_wp_admin()
if not auth:
html = self._base_page(
"Admin Access",
f"""
<header><strong>Calendar Fixture Admin</strong></header>
<main>
<div class="card">
<div class="err">Admin auth required (use <code>?as=admin</code> or <code>?as=editor</code> in fixture).</div>
<h3>Useful Links</h3>
<ul>
<li><a href="{self._with_slug('/admin.php')}?as=admin">Admin Index</a></li>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as=admin">Users</a></li>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as=admin">Setup</a></li>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as=admin">Diagnostics</a></li>
<li><a href="{self._with_slug('/calendar')}">Public Calendar</a></li>
</ul>
</div>
</main>
""",
)
self._write_text(401, html, "text/html; charset=utf-8")
return
query = parse_qs(parsed.query)
page_values = query.get("page") or []
page = page_values[0] if page_values else ""
as_user = (query.get("as") or ["admin"])[0]
allowed = {"calendar-users", "calendar-setup", "calendar-diagnostics"}
if page and page not in allowed:
self._write_text(404, "unknown admin page")
return
if page in {"calendar-users", "calendar-setup", "calendar-diagnostics"} and auth.role != "wp_admin":
self._write_text(403, "forbidden")
return
nav = f"""
<header>
<strong>Calendar Fixture Admin</strong> &nbsp;
<a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as={as_user}">Users</a>
<a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as={as_user}">Setup</a>
<a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as={as_user}">Diagnostics</a>
<a href="{self._with_slug('/calendar')}">Public Calendar</a>
</header>
<main>
<div class="card"><div class="muted">Authenticated as: {auth.actor_id} ({auth.role})</div></div>
"""
if not page:
body = nav + f"""
<div class="card">
<h3>Admin Pages</h3>
<ul>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-users')}&as={as_user}">Users</a></li>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-setup')}&as={as_user}">Setup</a></li>
<li><a href="{self._with_slug('/wp-admin/admin.php?page=calendar-diagnostics')}&as={as_user}">Diagnostics</a></li>
</ul>
</div>
</main>
"""
html = self._base_page("Admin Index", body)
if page == "calendar-users":
body = nav + self._admin_users_body(as_user) + "</main>"
html = self._base_page("Users", body)
elif page == "calendar-setup":
body = nav + self._admin_setup_body(as_user) + "</main>"
html = self._base_page("Setup", body)
elif page == "calendar-diagnostics":
body = nav + self._admin_diagnostics_body(as_user) + "</main>"
html = self._base_page("Diagnostics", body)
self._write_text(200, html, "text/html; charset=utf-8")
def _admin_edit_body(self, as_user: str) -> str:
return f"""
<div class="card">
<h3>Events</h3>
<div class="toolbar">
<div class="field"><label>From Date</label><input id="fromDate" type="date" /></div>
<div class="field"><label>Future Only</label><select id="futureOnly"><option value="0">No</option><option value="1">Yes</option></select></div>
<button id="newEventBtn">Create Event</button>
<button id="reloadBtn">Reload List</button>
</div>
</div>
<div class="card">
<h3>Events</h3>
<table><thead><tr><th>Title</th><th>Start</th><th>Repeat</th><th>Actions</th></tr></thead><tbody id="eventsBody"></tbody></table>
</div>
<div id="modal" class="modal">
<div class="modal-card">
<h3 id="modalTitle">Event</h3>
<input id="eventId" type="hidden" />
<div class="field"><label>Title</label><input id="title" /></div>
<div class="row">
<div class="field"><label>Location</label><input id="location" /></div>
<div class="field"><label>Category</label><input id="category" /></div>
</div>
<div class="field"><label>Description</label><textarea id="description" rows="3"></textarea></div>
<div class="toggle-wrap"><label><input id="allDay" type="checkbox" /> All Day Event</label></div>
<div class="grid4">
<div class="field"><label>Start Date</label><input id="startDate" type="date" /></div>
<div class="field" id="startTimeWrap"><label>Start Time</label><input id="startTime" type="time" /></div>
<div class="field"><label>End Date</label><input id="endDate" type="date" /></div>
<div class="field" id="endTimeWrap"><label>End Time</label><input id="endTime" type="time" /></div>
</div>
<div class="row">
<div class="field"><label>Repeat Type</label>
<select id="repeatType">
<option value="none">none</option>
<option value="daily">daily</option>
<option value="weekly">weekly</option>
<option value="monthly">monthly</option>
<option value="yearly">yearly</option>
<option value="custom">custom</option>
</select>
</div>
<div class="field field-with-suffix" id="repeatIntervalWrap"><label>Every</label><input id="repeatInterval" type="number" min="1" value="1" /><span id="repeatIntervalText" class="input-suffix"></span></div>
</div>
<div class="row" id="modeUntilRow">
<div class="field"><label>Range Mode</label>
<select id="rangeMode">
<option value="none">none</option>
<option value="no_end">no_end</option>
<option value="count">count</option>
<option value="until">until</option>
</select>
</div>
<div class="field" id="repeatUntilWrap"><label>Repeat Until</label><input id="repeatUntil" type="date" /></div>
</div>
<div class="row" id="rangeWrap">
<div class="field" id="repeatCountWrap"><label>Repeat Count</label><input id="repeatCount" type="number" min="1" /></div>
</div>
<div class="row" id="monthlyNthWrap">
<div class="field"><label>Monthly Pattern</label>
<select id="monthlyNthMode">
<option value="">from start date</option>
<option value="day_of_month">nth day of month</option>
<option value="weekday_of_month">nth weekday of month</option>
</select>
</div>
<div class="field" id="nthDayWrap"><label>Day Number</label><input id="nthDay" type="number" min="1" max="31" /></div>
</div>
<div class="row" id="nthWeekdayWrap">
<div class="field"><label>Nth</label><select id="nthPos"><option value="1">1st</option><option value="2">2nd</option><option value="3">3rd</option><option value="4">4th</option><option value="5">5th</option><option value="-1">last</option></select></div>
<div class="field"><label>Weekday</label><select id="nthWeekday"><option value="0">Sunday</option><option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option></select></div>
</div>
<div class="toolbar">
<button id="createBtn">Create</button>
<button id="updateBtn">Update</button>
<button id="deleteBtn">Delete</button>
<button id="toggleSingleOccurrenceBtn">Delete a Single Occurrence</button>
<button id="closeModalBtn">Close</button>
</div>
<div class="card" id="singleOccurrencePanel" style="display:none">
<h4>Delete Single Occurrence (Exception)</h4>
<div class="toolbar">
<button id="occPrevBtn">&#8592;</button>
<button id="occNextBtn">&#8594;</button>
</div>
<div id="occurrenceGrid" class="month-grid"></div>
<input id="occKey" type="hidden" />
<div class="field"><label>Occurrence</label><input id="occKeyDisplay" readonly placeholder="Select an occurrence from the calendar above" /></div>
<button id="deleteOccurrenceBtn">Delete Occurrence</button>
</div>
</div>
</div>
<pre id="out" class="card"></pre>
<script>
const AS = {json.dumps(as_user)};
const ROOT = {json.dumps(self._slug_prefix())};
const out = document.getElementById('out');
const eventsBody = document.getElementById('eventsBody');
const modalEl = document.getElementById('modal');
let occCursor = new Date();
document.getElementById('fromDate').value = new Date().toISOString().slice(0,10);
function apiUrl(path) {{
const full = ROOT + path;
const glue = full.includes('?') ? '&' : '?';
return full + glue + 'as=' + encodeURIComponent(AS);
}}
async function api(path, opts={{}}) {{
const resp = await fetch(apiUrl(path), {{
headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
...opts
}});
let payload = null;
const text = await resp.text();
try {{ payload = text ? JSON.parse(text) : {{}}; }} catch {{ payload = {{ raw: text }}; }}
out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
return {{ resp, payload }};
}}
function splitIsoToInputs(iso) {{
if (!iso) return {{ d: '', t: '' }};
if (!iso.includes('T')) return {{ d: iso.slice(0,10), t: '' }};
return {{ d: iso.slice(0,10), t: iso.slice(11,16) }};
}}
function toIso(d,t,allDay) {{
if (!d) return '';
if (allDay) return d;
return `${{d}}T${{(t||'00:00')}}:00+01:00`;
}}
function formatOccurrenceKey(iso) {{
if (!iso) return '';
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return iso;
return dt.toLocaleString('en-GB', {{ year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' }});
}}
function applyAllDayVisibility() {{
const allDay = !!document.getElementById('allDay').checked;
document.getElementById('startTimeWrap').style.display = allDay ? 'none' : '';
document.getElementById('endTimeWrap').style.display = allDay ? 'none' : '';
}}
function repeatUnit(rt) {{
if (rt === 'daily') return 'day';
if (rt === 'weekly' || rt === 'custom') return 'week';
if (rt === 'monthly') return 'month';
if (rt === 'yearly') return 'year';
return '';
}}
function repeatLabel(evt) {{
if (!evt || evt.repeat_type === 'none') return 'none';
const n = Number(evt.repeat_interval || 1);
const unit = repeatUnit(evt.repeat_type);
return unit ? `${{n}} ${{unit}}${{n===1?'':'s'}}` : evt.repeat_type;
}}
function everyText() {{
const n = Number(document.getElementById('repeatInterval').value || 1);
const rt = document.getElementById('repeatType').value;
const unit = repeatUnit(rt);
document.getElementById('repeatIntervalText').textContent = unit ? `${{unit}}${{n===1?'':'s'}}` : '';
}}
function applyRecurrenceVisibility() {{
const rt = document.getElementById('repeatType').value;
const rm = document.getElementById('rangeMode').value;
const showRecurring = rt !== 'none';
document.getElementById('repeatIntervalWrap').style.display = showRecurring ? '' : 'none';
document.getElementById('modeUntilRow').style.display = showRecurring ? '' : 'none';
document.getElementById('rangeWrap').style.display = (showRecurring && rm === 'count') ? '' : 'none';
document.getElementById('repeatCountWrap').style.display = (showRecurring && rm === 'count') ? '' : 'none';
document.getElementById('repeatUntilWrap').style.display = (showRecurring && rm === 'until') ? '' : 'none';
document.getElementById('monthlyNthWrap').style.display = (rt === 'monthly') ? '' : 'none';
const mm = document.getElementById('monthlyNthMode').value;
document.getElementById('nthDayWrap').style.display = (rt === 'monthly' && mm === 'day_of_month') ? '' : 'none';
document.getElementById('nthWeekdayWrap').style.display = (rt === 'monthly' && mm === 'weekday_of_month') ? '' : 'none';
document.getElementById('toggleSingleOccurrenceBtn').style.display = showRecurring ? '' : 'none';
if (!showRecurring) document.getElementById('singleOccurrencePanel').style.display = 'none';
everyText();
}}
async function loadOccurrenceChooser() {{
const id = document.getElementById('eventId').value;
if (!id) return;
const from = new Date(occCursor.getFullYear(), occCursor.getMonth(), 1);
const fromS = from.toISOString().slice(0,10);
const {{ payload }} = await api('/wp-json/calendar/v1/events/' + id + '/occurrences?from=' + fromS + '&months=3');
const list = payload?.data || [];
const dayToKey = new Map();
list.forEach(x => {{
const d = (x.occurrence_start || '').slice(0,10);
if (d && !dayToKey.has(d)) dayToKey.set(d, x.occurrence_start);
}});
const cells = [];
for (let m = 0; m < 3; m++) {{
const d = new Date(from.getFullYear(), from.getMonth() + m, 1);
const y = d.getFullYear(), mo = d.getMonth();
const first = new Date(y, mo, 1), off = (first.getDay() + 6) % 7, dim = new Date(y, mo + 1, 0).getDate();
let monthCells = '';
for (let i = 0; i < off; i++) monthCells += '<div class="month-cell blank"></div>';
for (let day = 1; day <= dim; day++) {{
const dt = new Date(y, mo, day);
const k = dt.toISOString().slice(0,10);
const has = dayToKey.has(k);
monthCells += `<div class="month-cell ${{has?'active':'blank'}}"><div class="day-num" data-occ-date="${{k}}" style="cursor:${{has?'pointer':'default'}}">${{day}}</div></div>`;
}}
cells.push(`<div class="year-month"><h4>${{d.toLocaleString('en-GB',{{month:'short',year:'numeric'}})}}</h4><div class="month-grid">${{monthCells}}</div></div>`);
}}
document.getElementById('occurrenceGrid').innerHTML = `<div class="year-grid">${{cells.join('')}}</div>`;
document.querySelectorAll('[data-occ-date]').forEach(el => el.addEventListener('click', () => {{
const k = el.getAttribute('data-occ-date');
if (!dayToKey.has(k)) return;
const iso = dayToKey.get(k) || '';
document.getElementById('occKey').value = iso;
document.getElementById('occKeyDisplay').value = formatOccurrenceKey(iso);
updateEventButtons();
}}));
}}
function openModal(mode, evt) {{
if (!evt) evt = {{}};
const sd = splitIsoToInputs(evt.start_datetime);
const ed = splitIsoToInputs(evt.end_datetime);
document.getElementById('modalTitle').textContent = mode === 'create' ? 'Create Event' : 'Edit Event';
document.getElementById('eventId').value = evt.id || '';
document.getElementById('title').value = evt.title || '';
document.getElementById('location').value = evt.location || '';
document.getElementById('category').value = evt.category || '';
document.getElementById('description').value = evt.description || '';
document.getElementById('allDay').checked = !!evt.all_day_event;
document.getElementById('startDate').value = sd.d || '';
document.getElementById('startTime').value = sd.t || '';
document.getElementById('endDate').value = ed.d || '';
document.getElementById('endTime').value = ed.t || '';
document.getElementById('repeatType').value = evt.repeat_type || 'none';
document.getElementById('repeatInterval').value = evt.repeat_interval || 1;
document.getElementById('rangeMode').value = evt.repeat_range_mode || 'none';
document.getElementById('repeatCount').value = evt.repeat_count || '';
document.getElementById('repeatUntil').value = evt.repeat_until || '';
document.getElementById('monthlyNthMode').value = evt.repeat_nth_mode || '';
document.getElementById('nthDay').value = evt.repeat_nth_day || '';
document.getElementById('nthPos').value = evt.repeat_nth_pos || '1';
document.getElementById('nthWeekday').value = evt.repeat_nth_weekday === null || evt.repeat_nth_weekday === undefined ? '0' : String(evt.repeat_nth_weekday);
document.getElementById('singleOccurrencePanel').style.display = 'none';
document.getElementById('occKey').value = '';
document.getElementById('occKeyDisplay').value = '';
occCursor = new Date((evt.start_datetime || new Date().toISOString()).slice(0,10) + 'T00:00:00');
document.getElementById('createBtn').style.display = mode === 'create' ? '' : 'none';
document.getElementById('updateBtn').style.display = mode === 'edit' ? '' : 'none';
document.getElementById('deleteBtn').style.display = mode === 'edit' ? '' : 'none';
applyRecurrenceVisibility();
applyAllDayVisibility();
updateEventButtons();
modalEl.classList.add('open');
}}
function closeModal() {{ modalEl.classList.remove('open'); }}
function validateEventForm() {{
const title = (document.getElementById('title').value || '').trim();
const startDate = (document.getElementById('startDate').value || '').trim();
const endDateInput = (document.getElementById('endDate').value || '').trim();
const endDate = endDateInput || startDate;
const allDay = !!document.getElementById('allDay').checked;
const startTime = (document.getElementById('startTime').value || '').trim();
const endTime = (document.getElementById('endTime').value || '').trim();
if (!title || !startDate || !endDate) return false;
if (endDate < startDate) return false;
if (allDay) return true;
if (!startTime || !endTime) return false;
if (endDate === startDate && endTime < startTime) return false;
return true;
}}
function updateEventButtons() {{
const hasId = !!(document.getElementById('eventId').value || '').trim();
const valid = validateEventForm();
document.getElementById('createBtn').disabled = !(valid && !hasId);
document.getElementById('updateBtn').disabled = !(valid && hasId);
document.getElementById('deleteBtn').disabled = !hasId;
document.getElementById('toggleSingleOccurrenceBtn').disabled = !(hasId && document.getElementById('repeatType').value !== 'none');
document.getElementById('deleteOccurrenceBtn').disabled = !(hasId && (document.getElementById('occKey').value || '').trim());
}}
function getFormPayload() {{
const allDay = document.getElementById('allDay').checked;
return {{
title: document.getElementById('title').value,
location: document.getElementById('location').value,
category: document.getElementById('category').value,
description: document.getElementById('description').value,
all_day_event: allDay,
start_datetime: toIso(document.getElementById('startDate').value, document.getElementById('startTime').value, allDay),
end_datetime: toIso((document.getElementById('endDate').value || document.getElementById('startDate').value), document.getElementById('endTime').value, allDay),
repeat_type: document.getElementById('repeatType').value,
repeat_interval: Number(document.getElementById('repeatInterval').value || 1),
repeat_nth_mode: document.getElementById('monthlyNthMode').value,
repeat_nth_day: document.getElementById('nthDay').value ? Number(document.getElementById('nthDay').value) : null,
repeat_nth_pos: document.getElementById('nthPos').value ? Number(document.getElementById('nthPos').value) : null,
repeat_nth_weekday: document.getElementById('nthWeekday').value === '' ? null : Number(document.getElementById('nthWeekday').value),
repeat_range_mode: document.getElementById('rangeMode').value,
repeat_count: document.getElementById('repeatCount').value ? Number(document.getElementById('repeatCount').value) : null,
repeat_until: document.getElementById('repeatUntil').value || null
}};
}}
async function loadEvents() {{
const {{ payload }} = await api('/wp-json/calendar/v1/events');
let events = payload?.data || [];
const from = document.getElementById('fromDate').value;
if (from) events = events.filter(e => (e.start_datetime || '').slice(0,10) >= from);
if (document.getElementById('futureOnly').value === '1') {{
const today = new Date().toISOString().slice(0,10);
events = events.filter(e => (e.start_datetime || '').slice(0,10) >= today);
}}
eventsBody.innerHTML = events.map(evt => `
<tr>
<td>${{evt.title}}</td>
<td>${{evt.start_datetime}}</td>
<td>${{repeatLabel(evt)}}</td>
<td><button data-id="${{evt.id}}">Edit</button></td>
</tr>
`).join('');
eventsBody.querySelectorAll('button[data-id]').forEach(btn => {{
btn.addEventListener('click', () => {{
const evt = events.find(e => String(e.id) === btn.dataset.id);
if (evt) openModal('edit', evt);
}});
}});
}}
document.getElementById('newEventBtn').onclick = () => openModal('create', {{}});
document.getElementById('closeModalBtn').onclick = closeModal;
document.getElementById('createBtn').onclick = () => {{
if (!validateEventForm()) return;
return api('/wp-json/calendar/v1/events', {{ method:'POST', body: JSON.stringify(getFormPayload()) }}).then(() => {{ closeModal(); return loadEvents(); }});
}};
document.getElementById('updateBtn').onclick = () => {{
const id = document.getElementById('eventId').value;
if (!id) return;
if (!validateEventForm()) return;
return api('/wp-json/calendar/v1/events/' + id, {{ method:'PATCH', body: JSON.stringify(getFormPayload()) }}).then(() => {{ closeModal(); return loadEvents(); }});
}};
document.getElementById('deleteBtn').onclick = () => {{
const id = document.getElementById('eventId').value;
if (!id) return;
return api('/wp-json/calendar/v1/events/' + id, {{ method:'DELETE' }}).then(() => {{ closeModal(); return loadEvents(); }});
}};
document.getElementById('toggleSingleOccurrenceBtn').onclick = async () => {{
const p = document.getElementById('singleOccurrencePanel');
p.style.display = p.style.display === 'none' ? '' : 'none';
if (p.style.display !== 'none') await loadOccurrenceChooser();
}};
document.getElementById('occPrevBtn').onclick = async () => {{
occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth() - 1, 1);
await loadOccurrenceChooser();
}};
document.getElementById('occNextBtn').onclick = async () => {{
occCursor = new Date(occCursor.getFullYear(), occCursor.getMonth() + 1, 1);
await loadOccurrenceChooser();
}};
document.getElementById('deleteOccurrenceBtn').onclick = async () => {{
const id = document.getElementById('eventId').value;
const key = encodeURIComponent(document.getElementById('occKey').value);
if (!id || !key) return;
await api('/wp-json/calendar/v1/events/' + id + '/occurrences/' + key, {{ method:'DELETE' }});
await loadOccurrenceChooser();
await loadEvents();
}};
document.getElementById('repeatType').onchange = applyRecurrenceVisibility;
document.getElementById('rangeMode').onchange = applyRecurrenceVisibility;
document.getElementById('monthlyNthMode').onchange = applyRecurrenceVisibility;
document.getElementById('repeatInterval').oninput = everyText;
document.getElementById('allDay').onchange = () => {{ applyAllDayVisibility(); updateEventButtons(); }};
['title','startDate','endDate','startTime','endTime','repeatType','occKey'].forEach(id => {{
document.getElementById(id).addEventListener('input', updateEventButtons);
}});
document.getElementById('reloadBtn').onclick = loadEvents;
document.getElementById('fromDate').onchange = loadEvents;
document.getElementById('futureOnly').onchange = loadEvents;
applyAllDayVisibility();
updateEventButtons();
loadEvents();
</script>
"""
def _admin_users_body(self, as_user: str) -> str:
return f"""
<div class="card">
<h3>Users</h3>
<button id="reloadUsers">Reload</button>
<table>
<thead><tr><th>ID</th><th>Email</th><th>Email Verified</th><th>Status</th><th>Action</th></tr></thead>
<tbody id="usersBody"></tbody>
</table>
</div>
<pre id="out" class="card"></pre>
<script>
const AS = {json.dumps(as_user)};
const ROOT = {json.dumps(self._slug_prefix())};
const out = document.getElementById('out');
const usersBody = document.getElementById('usersBody');
function apiUrl(path) {{
const full = ROOT + path;
const glue = full.includes('?') ? '&' : '?';
return full + glue + 'as=' + encodeURIComponent(AS);
}}
async function api(path, opts={{}}) {{
const resp = await fetch(apiUrl(path), {{
headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
...opts
}});
let payload = null;
const raw = await resp.text();
if (raw) {{
try {{ payload = JSON.parse(raw); }} catch (_err) {{ payload = {{ raw }}; }}
}}
out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
return payload;
}}
async function loadUsers() {{
const payload = await api('/wp-json/calendar/v1/admin/users');
const users = payload.data || [];
usersBody.innerHTML = users.map(u => `
<tr>
<td>${{u.id}}</td>
<td>${{u.email}}</td>
<td>${{u.email_verified_at ? 'yes' : 'no'}}</td>
<td><select data-kind="status" data-id="${{u.id}}">
${{['pending_approval','active'].map(v=>`<option ${{u.account_status===v?'selected':''}}>${{v}}</option>`).join('')}}
</select></td>
<td><button data-save="${{u.id}}">Save</button> <button data-del="${{u.id}}">Remove</button></td>
</tr>`).join('');
usersBody.querySelectorAll('button[data-save]').forEach(btn => {{
btn.addEventListener('click', async () => {{
const id = btn.dataset.save;
const status = usersBody.querySelector(`select[data-kind="status"][data-id="${{id}}"]`).value;
await api('/wp-json/calendar/v1/admin/users/' + id, {{
method:'PATCH',
body: JSON.stringify({{ account_status: status }})
}});
await loadUsers();
}});
}});
usersBody.querySelectorAll('button[data-del]').forEach(btn => {{
btn.addEventListener('click', async () => {{
const id = btn.dataset.del;
await api('/wp-json/calendar/v1/admin/users/' + id, {{ method:'DELETE' }});
await loadUsers();
}});
}});
}}
document.getElementById('reloadUsers').onclick = loadUsers;
loadUsers();
</script>
"""
def _admin_setup_body(self, as_user: str) -> str:
return f"""
<div class="card">
<h3>Setup</h3>
<div class="field"><label>Presentation Name</label><input id="presentationName" placeholder="presentation name" /></div>
<div class="field"><label>CalDAV Calendar Name</label><input id="caldavCalendarName" placeholder="Team Calendar" /></div>
<div class="field"><label>URL Slug (prefix for all routes)</label><input id="urlSlug" placeholder="calendar" /></div>
<div class="field"><label>ICS Access Mode</label><select id="icsMode">
<option value="public_read">public_read</option>
<option value="authenticated_read">authenticated_read</option>
</select></div>
<button id="saveSetup">Save Setup</button>
<button id="reloadSetup">Reload</button>
</div>
<div class="card">
<h3>Shortcode Semantics</h3>
<p><code>[calendar]</code> renders the full interactive calendar page (views, filters, login, and event edit for approved users).</p>
<p><code>[calendar_sidebar_upcoming]</code> renders a compact upcoming-events list for sidebar/widget use, showing occurrences for the next 14 days.</p>
<p>If <code>URL Slug</code> is set, all plugin routes are prefixed with that slug (for example: <code>/my-slug/calendar</code>, <code>/my-slug/calendar-sidebar</code>, <code>/my-slug/calendar.ics</code>, <code>/my-slug/caldav/</code>).</p>
</div>
<pre id="out" class="card"></pre>
<script>
const AS = {json.dumps(as_user)};
const ROOT = {json.dumps(self._slug_prefix())};
const out = document.getElementById('out');
function apiUrl(path) {{
const full = ROOT + path;
const glue = full.includes('?') ? '&' : '?';
return full + glue + 'as=' + encodeURIComponent(AS);
}}
async function api(path, opts={{}}) {{
const resp = await fetch(apiUrl(path), {{
headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
...opts
}});
const payload = await resp.json();
out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
return payload;
}}
async function loadSetup() {{
const payload = await api('/wp-json/calendar/v1/admin/setup');
document.getElementById('presentationName').value = payload?.data?.presentation_name || 'Calendar';
document.getElementById('caldavCalendarName').value = payload?.data?.caldav_calendar_name || 'Public Calendar';
document.getElementById('urlSlug').value = payload?.data?.url_slug || '';
document.getElementById('icsMode').value = payload?.data?.ics_access_mode || 'public_read';
}}
document.getElementById('saveSetup').onclick = async () => {{
await api('/wp-json/calendar/v1/admin/setup', {{
method:'PATCH',
body: JSON.stringify({{
presentation_name: document.getElementById('presentationName').value,
caldav_calendar_name: document.getElementById('caldavCalendarName').value,
url_slug: document.getElementById('urlSlug').value,
ics_access_mode: document.getElementById('icsMode').value
}})
}});
await loadSetup();
}};
document.getElementById('reloadSetup').onclick = loadSetup;
loadSetup();
</script>
"""
def _admin_diagnostics_body(self, as_user: str) -> str:
return f"""
<div class="card">
<h3>Diagnostics</h3>
<p class="muted">Shows the most recent HTTP trace events (requests/responses) from the fixture log.</p>
<button id="reloadDiag">Reload</button>
<table>
<thead><tr><th>Timestamp</th><th>Method</th><th>Path</th><th>Status</th><th>Request</th><th>Response</th></tr></thead>
<tbody id="diagBody"></tbody>
</table>
</div>
<pre id="out" class="card"></pre>
<script>
const AS = {json.dumps(as_user)};
const ROOT = {json.dumps(self._slug_prefix())};
const out = document.getElementById('out');
const diagBody = document.getElementById('diagBody');
function esc(v) {{
const s = String(v == null ? '' : v);
return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
}}
function apiUrl(path) {{
const full = ROOT + path;
const glue = full.includes('?') ? '&' : '?';
return full + glue + 'as=' + encodeURIComponent(AS);
}}
async function api(path, opts={{}}) {{
const resp = await fetch(apiUrl(path), {{
headers: {{ 'Content-Type':'application/json', 'X-WP-User': AS, ...(opts.headers||{{}}) }},
...opts
}});
const text = await resp.text();
let payload = null;
try {{ payload = text ? JSON.parse(text) : {{}}; }} catch (_err) {{ payload = {{ raw: text }}; }}
out.textContent = JSON.stringify({{ status: resp.status, payload }}, null, 2);
return payload;
}}
async function loadDiagnostics() {{
const payload = await api('/wp-json/calendar/v1/admin/diagnostics?limit=20');
const rows = payload?.data || [];
diagBody.innerHTML = rows.map(r => `
<tr>
<td>${{esc(r.ts || '')}}</td>
<td>${{esc(r.method || '')}}</td>
<td><code>${{esc(r.path || '')}}</code></td>
<td>${{esc(r.response?.status || '')}}</td>
<td><code>${{esc(r.request?.body || '')}}</code></td>
<td><code>${{esc(r.response?.body || '')}}</code></td>
</tr>`).join('');
}}
document.getElementById('reloadDiag').onclick = loadDiagnostics;
loadDiagnostics();
</script>
"""
def _handle_api_get(self, path: str, parsed) -> None:
if path == f"{API_BASE}/public/events":
query = parse_qs(parsed.query)
view = (query.get("view") or ["month"])[0]
date_str = (query.get("date") or [date.today().isoformat()])[0]
try:
anchor = date.fromisoformat(date_str)
except ValueError:
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid date"}})
return
window_start, window_end = _window_for_view(view, anchor)
conn = ensure_db()
rows = conn.execute("SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)).fetchall()
ex_rows = conn.execute(
"SELECT event_id, occurrence_key FROM recurrence_exceptions WHERE exception_type = 'deleted_occurrence'"
).fetchall()
conn.close()
ex_map: Dict[int, set] = {}
for ex in ex_rows:
ex_map.setdefault(ex["event_id"], set()).add(ex["occurrence_key"])
occurrences: List[Dict[str, Any]] = []
for row in rows:
evt = event_to_dict(row)
occurrences.extend(self._expand_event(evt, window_start, window_end, ex_map.get(evt["id"], set())))
occurrences.sort(key=lambda x: x["occurrence_start"])
self._write_json(200, {"data": occurrences, "meta": {"count": len(occurrences), "view": view}})
return
if path == f"{API_BASE}/public/sidebar-events":
anchor = date.today()
window_start = datetime.fromisoformat(f"{anchor.isoformat()}T00:00:00+00:00")
window_end = window_start + timedelta(days=14)
conn = ensure_db()
rows = conn.execute("SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)).fetchall()
ex_rows = conn.execute(
"SELECT event_id, occurrence_key FROM recurrence_exceptions WHERE exception_type = 'deleted_occurrence'"
).fetchall()
conn.close()
ex_map: Dict[int, set] = {}
for ex in ex_rows:
ex_map.setdefault(ex["event_id"], set()).add(ex["occurrence_key"])
occurrences: List[Dict[str, Any]] = []
for row in rows:
evt = event_to_dict(row)
occurrences.extend(self._expand_event(evt, window_start, window_end, ex_map.get(evt["id"], set())))
occurrences.sort(key=lambda x: x["occurrence_start"])
self._write_json(200, {"data": occurrences, "meta": {"count": len(occurrences), "window_days": 14}})
return
if path == f"{API_BASE}/events":
auth = self._require_event_read_auth()
if not auth:
return
conn = ensure_db()
rows = conn.execute(
"SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)
).fetchall()
conn.close()
items = [event_to_dict(r) for r in rows]
self._write_json(200, {"data": items, "meta": {"count": len(items)}})
return
m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)", path)
if m:
auth = self._require_event_read_auth()
if not auth:
return
event_id = int(m.group(1))
conn = ensure_db()
row = conn.execute(
"SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)
).fetchone()
conn.close()
if not row:
self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
return
self._write_json(200, {"data": event_to_dict(row)})
return
if path == f"{API_BASE}/admin/users":
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
conn = ensure_db()
rows = conn.execute(
"SELECT id, email, email_verified_at, account_status, updated_at FROM caldav_users ORDER BY id ASC"
).fetchall()
conn.close()
self._write_json(200, {"data": [dict(r) for r in rows]})
return
m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)/occurrences", path)
if m:
auth = self._require_event_read_auth()
if not auth:
return
event_id = int(m.group(1))
query = parse_qs(parsed.query)
from_s = (query.get("from") or [date.today().isoformat()])[0]
months = int((query.get("months") or ["3"])[0])
try:
from_d = date.fromisoformat(from_s)
except ValueError:
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid from date"}})
return
start = datetime.fromisoformat(f"{from_d.isoformat()}T00:00:00+00:00")
end = _add_months(start, max(1, min(months, 12)))
conn = ensure_db()
row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
return
ex_rows = conn.execute(
"SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence'",
(event_id,),
).fetchall()
conn.close()
deleted = {r["occurrence_key"] for r in ex_rows}
occ = self._expand_event(event_to_dict(row), start, end, deleted)
self._write_json(200, {"data": occ})
return
if path == f"{API_BASE}/admin/setup":
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
conn = ensure_db()
payload = {
"presentation_name": get_setting(conn, "presentation_name", "Calendar"),
"caldav_calendar_name": get_setting(conn, "caldav_calendar_name", "Public Calendar"),
"url_slug": get_setting(conn, "url_slug", ""),
"ics_access_mode": get_setting(conn, "ics_access_mode", "public_read"),
}
conn.close()
self._write_json(200, {"data": payload})
return
if path == f"{API_BASE}/admin/diagnostics":
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
query = parse_qs(parsed.query)
try:
limit = int((query.get("limit") or ["20"])[0])
except ValueError:
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid limit"}})
return
entries = self._read_trace_entries(limit=max(1, min(limit, 200)))
self._write_json(200, {"data": entries, "meta": {"count": len(entries)}})
return
if path == f"{API_BASE}/users/me":
creds = parse_basic_auth(self.headers.get("Authorization"))
email_hint = (creds[0].strip().lower() if creds else "unknown")
if _rate_limited("user_login", f"{self.client_address[0]}:{email_hint}", limit=20, window_seconds=300):
self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many login attempts. Try again later."}})
return
auth = self._authenticate_caldav_user()
if not auth:
self._write_json(401, {"error": {"code": "authentication_error", "message": "Login failure"}})
return
conn = ensure_db()
row = conn.execute(
"SELECT id, email, account_status, email_verified_at FROM caldav_users WHERE id = ?",
(int(auth.actor_id),),
).fetchone()
conn.close()
self._write_json(200, {"data": dict(row)})
return
self._not_found()
def _handle_api_post(self, path: str) -> None:
if path == f"{API_BASE}/events":
auth = self._require_event_write_auth()
if not auth:
return
payload = self._json_body()
normalized = self._validate_event_payload(payload)
if normalized is None:
return
now = utc_now_iso()
uid = payload.get("uid") or make_uid(f"{normalized['title']}:{normalized['start_datetime']}:{now}")
etag = mk_etag(f"{uid}:{now}:1")
conn = ensure_db()
cur = conn.execute(
"""
INSERT INTO events
(uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
repeat_range_mode, repeat_count, repeat_until, timezone,
calendar_id, caldav_resource, etag, sync_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
uid,
normalized["title"],
normalized["description"],
normalized["location"],
normalized["category"],
normalized["all_day_event"],
normalized["start_datetime"],
normalized["end_datetime"],
normalized["repeat_type"],
normalized["repeat_interval"],
normalized["repeat_nth_mode"],
normalized["repeat_nth_day"],
normalized["repeat_nth_pos"],
normalized["repeat_nth_weekday"],
normalized["repeat_range_mode"],
normalized["repeat_count"],
normalized["repeat_until"],
normalized["timezone"],
SHARED_CALENDAR_ID,
None,
etag,
1,
now,
now,
),
)
event_id = cur.lastrowid
conn.execute("UPDATE events SET caldav_resource = ? WHERE id = ?", (f"{event_id}.ics", event_id))
conn.commit()
row = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
log_audit(conn, auth.actor_type, auth.actor_id, "event.create", "event", str(event_id), "success", {})
conn.close()
self._write_json(201, {"data": event_to_dict(row)})
return
if path == f"{API_BASE}/users/register":
if _rate_limited("user_register", self.client_address[0], limit=8, window_seconds=300):
self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many registration attempts. Try again later."}})
return
payload = self._json_body()
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
if not email or len(password) < 8:
self._write_json(422, {"error": {"code": "validation_error", "message": "email and strong password required"}})
return
now = utc_now_iso()
conn = ensure_db()
try:
cur = conn.execute(
"""
INSERT INTO caldav_users
(email, password_hash, email_verified_at, account_status, access_level, request_state, created_at, updated_at)
VALUES (?, ?, NULL, 'pending_approval', 'write', 'requested', ?, ?)
""",
(email, hash_password(password), now, now),
)
except sqlite3.IntegrityError:
conn.close()
self._write_json(409, {"error": {"code": "conflict_error", "message": "user already exists"}})
return
user_id = cur.lastrowid
token_plain = secrets.token_urlsafe(24)
conn.execute(
"""
INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at, used_at, created_at)
VALUES (?, 'verify_email', ?, ?, NULL, ?)
""",
(user_id, hash_password(token_plain), (datetime.now(timezone.utc) + timedelta(seconds=VERIFY_TOKEN_TTL_SECONDS)).replace(microsecond=0).isoformat(), now),
)
conn.commit()
conn.close()
sent, send_info = send_fixture_email(
email,
"Calendar account verification",
f"Your verification token is: {token_plain}\nUse this token in the calendar login dialog to verify your email.",
)
self._write_json(
201,
{
"data": {
"user_id": user_id,
"status": "pending_approval",
"email_status": send_info,
"email_sent": sent,
}
},
)
return
if path == f"{API_BASE}/users/verify":
if _rate_limited("user_verify", self.client_address[0], limit=20, window_seconds=300):
self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many verification attempts. Try again later."}})
return
payload = self._json_body()
token = payload.get("token") or ""
conn = ensure_db()
now = utc_now_iso()
candidates = conn.execute(
"""
SELECT id, user_id, token_hash, expires_at FROM user_tokens
WHERE token_type = 'verify_email' AND used_at IS NULL
ORDER BY id DESC
"""
).fetchall()
row = None
for cand in candidates:
if cand["expires_at"] < now:
continue
if verify_password(token, cand["token_hash"]):
row = cand
break
if not row:
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid token"}})
return
conn.execute("UPDATE user_tokens SET used_at = ? WHERE id = ?", (now, row["id"]))
conn.execute(
"UPDATE caldav_users SET email_verified_at = ?, account_status = 'pending_approval', request_state = 'requested', updated_at = ? WHERE id = ?",
(now, now, row["user_id"]),
)
conn.commit()
requester = conn.execute("SELECT email FROM caldav_users WHERE id = ?", (row["user_id"],)).fetchone()
conn.close()
cfg = _smtp_settings()
admin_to = (cfg.get("SMTP_ADMIN_TO") or "").strip()
sent = False
send_info = "admin_email_not_configured"
if admin_to:
req_email = requester["email"] if requester else f"user_id={row['user_id']}"
host = (self.headers.get("Host") or "127.0.0.1:8080").strip()
proto = (self.headers.get("X-Forwarded-Proto") or "http").strip().lower()
scheme = "https" if proto == "https" else "http"
approval_path = self._with_slug("/wp-admin/admin.php?page=calendar-users") + "&as=admin"
approval_url = f"{scheme}://{host}{approval_path}"
sent, send_info = send_fixture_email(
admin_to,
"Calendar registration pending approval",
f"User {req_email} (id {row['user_id']}) verified email and is awaiting approval.\nApprove here: {approval_url}",
)
self._write_json(200, {"data": {"status": "pending_approval", "email_status": send_info, "email_sent": sent}})
return
if path == f"{API_BASE}/users/forgot-password":
if _rate_limited("user_forgot", self.client_address[0], limit=12, window_seconds=300):
self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many password reset requests. Try again later."}})
return
payload = self._json_body()
email = (payload.get("email") or "").strip().lower()
conn = ensure_db()
row = conn.execute("SELECT id FROM caldav_users WHERE email = ?", (email,)).fetchone()
token_plain = None
if row:
now = utc_now_iso()
token_plain = secrets.token_urlsafe(24)
conn.execute(
"""
INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at, used_at, created_at)
VALUES (?, 'reset_password', ?, ?, NULL, ?)
""",
(row["id"], hash_password(token_plain), (datetime.now(timezone.utc) + timedelta(seconds=RESET_TOKEN_TTL_SECONDS)).replace(microsecond=0).isoformat(), now),
)
conn.commit()
conn.close()
sent = False
send_info = "no_matching_user"
if token_plain:
sent, send_info = send_fixture_email(
email,
"Calendar password reset",
f"Your password reset token is: {token_plain}\nUse this token in the calendar login dialog to set a new password.",
)
self._write_json(200, {"data": {"status": "ok", "email_status": send_info, "email_sent": sent}})
return
if path == f"{API_BASE}/users/reset-password":
if _rate_limited("user_reset", self.client_address[0], limit=20, window_seconds=300):
self._write_json(429, {"error": {"code": "rate_limited", "message": "Too many reset attempts. Try again later."}})
return
payload = self._json_body()
token = payload.get("token") or ""
new_password = payload.get("new_password") or ""
if len(new_password) < 8:
self._write_json(422, {"error": {"code": "validation_error", "message": "weak password"}})
return
conn = ensure_db()
now = utc_now_iso()
candidates = conn.execute(
"""
SELECT id, user_id, token_hash, expires_at FROM user_tokens
WHERE token_type = 'reset_password' AND used_at IS NULL
ORDER BY id DESC
"""
).fetchall()
row = None
for cand in candidates:
if cand["expires_at"] < now:
continue
if verify_password(token, cand["token_hash"]):
row = cand
break
if not row:
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid token"}})
return
conn.execute("UPDATE user_tokens SET used_at = ? WHERE id = ?", (now, row["id"]))
conn.execute("UPDATE caldav_users SET password_hash = ?, updated_at = ? WHERE id = ?", (hash_password(new_password), now, row["user_id"]))
conn.commit()
conn.close()
self._write_json(200, {"data": {"status": "password_reset"}})
return
self._not_found()
def _handle_api_put(self, path: str) -> None:
self._handle_api_patch(path)
def _handle_api_patch(self, path: str) -> None:
m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)", path)
if m:
auth = self._require_event_write_auth()
if not auth:
return
event_id = int(m.group(1))
payload = self._json_body()
normalized = self._validate_event_payload(payload)
if normalized is None:
return
conn = ensure_db()
row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
return
if_match = self.headers.get("If-Match")
if if_match and if_match != row["etag"]:
conn.close()
self._write_json(412, {"error": {"code": "precondition_failed", "message": "etag mismatch"}})
return
now = utc_now_iso()
sync_version = int(row["sync_version"]) + 1
etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
conn.execute(
"""
UPDATE events
SET title = ?, description = ?, location = ?, category = ?, all_day_event = ?,
start_datetime = ?, end_datetime = ?, repeat_type = ?, repeat_interval = ?,
repeat_nth_mode = ?, repeat_nth_day = ?, repeat_nth_pos = ?, repeat_nth_weekday = ?,
repeat_range_mode = ?, repeat_count = ?, repeat_until = ?, timezone = ?,
etag = ?, sync_version = ?, updated_at = ?
WHERE id = ?
""",
(
normalized["title"],
normalized["description"],
normalized["location"],
normalized["category"],
normalized["all_day_event"],
normalized["start_datetime"],
normalized["end_datetime"],
normalized["repeat_type"],
normalized["repeat_interval"],
normalized["repeat_nth_mode"],
normalized["repeat_nth_day"],
normalized["repeat_nth_pos"],
normalized["repeat_nth_weekday"],
normalized["repeat_range_mode"],
normalized["repeat_count"],
normalized["repeat_until"],
normalized["timezone"],
etag,
sync_version,
now,
event_id,
),
)
conn.commit()
updated = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
log_audit(conn, auth.actor_type, auth.actor_id, "event.update", "event", str(event_id), "success", {})
conn.close()
self._write_json(200, {"data": event_to_dict(updated)})
return
m = re.fullmatch(rf"{re.escape(API_BASE)}/admin/users/(\d+)", path)
if m:
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
user_id = int(m.group(1))
payload = self._json_body()
account_status = payload.get("account_status")
conn = ensure_db()
row = conn.execute("SELECT * FROM caldav_users WHERE id = ?", (user_id,)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "user not found"}})
return
updates = []
params: List[Any] = []
if account_status in {"pending_approval", "active"}:
if account_status == "active" and not row["email_verified_at"]:
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "email must be verified before approval"}})
return
updates.append("account_status = ?")
params.append(account_status)
updates.append("request_state = ?")
params.append("approved" if account_status == "active" else "requested")
if not updates:
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "valid account_status required"}})
return
if account_status == "active":
updates.append("access_level = ?")
params.append("write")
updates.append("updated_at = ?")
params.append(utc_now_iso())
params.append(user_id)
conn.execute(f"UPDATE caldav_users SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
updated = conn.execute(
"SELECT id, email, email_verified_at, account_status, updated_at FROM caldav_users WHERE id = ?",
(user_id,),
).fetchone()
log_audit(conn, auth.actor_type, auth.actor_id, "user.update", "caldav_user", str(user_id), "success", payload)
conn.close()
self._write_json(200, {"data": dict(updated)})
return
if path == f"{API_BASE}/admin/setup":
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
payload = self._json_body()
presentation_name = (payload.get("presentation_name") or "").strip()
caldav_calendar_name = (payload.get("caldav_calendar_name") or "").strip()
raw_slug = (payload.get("url_slug") or "").strip().strip("/")
ics_access_mode = payload.get("ics_access_mode")
if not presentation_name:
self._write_json(422, {"error": {"code": "validation_error", "message": "presentation_name required"}})
return
if not caldav_calendar_name:
self._write_json(422, {"error": {"code": "validation_error", "message": "caldav_calendar_name required"}})
return
if raw_slug and not re.fullmatch(r"[a-z0-9-]+", raw_slug):
self._write_json(422, {"error": {"code": "validation_error", "message": "url_slug must match [a-z0-9-]+"}})
return
if ics_access_mode not in {"public_read", "authenticated_read"}:
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid ics_access_mode"}})
return
conn = ensure_db()
set_setting(conn, "presentation_name", presentation_name)
set_setting(conn, "caldav_calendar_name", caldav_calendar_name)
set_setting(conn, "url_slug", raw_slug)
set_setting(conn, "ics_access_mode", ics_access_mode)
log_audit(conn, auth.actor_type, auth.actor_id, "setup.update", "plugin_settings", "global", "success", payload)
conn.close()
self._write_json(200, {"data": {"presentation_name": presentation_name, "caldav_calendar_name": caldav_calendar_name, "url_slug": raw_slug, "ics_access_mode": ics_access_mode}})
return
self._not_found()
def _handle_api_delete(self, path: str) -> None:
m = re.fullmatch(rf"{re.escape(API_BASE)}/admin/users/(\d+)$", path)
if m:
auth = self._require_api_admin()
if not auth:
return
if auth.role != "wp_admin":
self._write_json(403, {"error": {"code": "authorization_error", "message": "admin role required"}})
return
user_id = int(m.group(1))
conn = ensure_db()
row = conn.execute("SELECT id FROM caldav_users WHERE id = ?", (user_id,)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "user not found"}})
return
conn.execute("DELETE FROM user_tokens WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM caldav_users WHERE id = ?", (user_id,))
conn.commit()
log_audit(conn, auth.actor_type, auth.actor_id, "user.delete", "caldav_user", str(user_id), "success", {})
conn.close()
self.send_response(204)
self.end_headers()
return
m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)$", path)
if m:
auth = self._require_event_write_auth()
if not auth:
return
event_id = int(m.group(1))
conn = ensure_db()
row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
return
conn.execute("DELETE FROM recurrence_exceptions WHERE event_id = ?", (event_id,))
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
conn.commit()
log_audit(conn, auth.actor_type, auth.actor_id, "event.delete", "event", str(event_id), "success", {})
conn.close()
self.send_response(204)
self.end_headers()
return
m = re.fullmatch(rf"{re.escape(API_BASE)}/events/(\d+)/occurrences/(.+)$", path)
if m:
auth = self._require_event_write_auth()
if not auth:
return
event_id = int(m.group(1))
occurrence_key = unquote(m.group(2))
conn = ensure_db()
row = conn.execute("SELECT * FROM events WHERE id = ? AND calendar_id = ?", (event_id, SHARED_CALENDAR_ID)).fetchone()
if not row:
conn.close()
self._write_json(404, {"error": {"code": "not_found", "message": "event not found"}})
return
if row["repeat_type"] == "none":
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "event is not recurring"}})
return
normalized_occurrence_key = _normalize_occurrence_key_for_event(occurrence_key, row["start_datetime"])
if not normalized_occurrence_key:
conn.close()
self._write_json(422, {"error": {"code": "validation_error", "message": "invalid occurrence key"}})
return
now = utc_now_iso()
try:
conn.execute(
"""
INSERT INTO recurrence_exceptions (event_id, occurrence_key, exception_type, override_payload, created_at, updated_at)
VALUES (?, ?, 'deleted_occurrence', NULL, ?, ?)
""",
(event_id, normalized_occurrence_key, now, now),
)
sync_version = int(row["sync_version"]) + 1
etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
conn.execute("UPDATE events SET etag = ?, sync_version = ?, updated_at = ? WHERE id = ?", (etag, sync_version, now, event_id))
conn.commit()
log_audit(
conn,
auth.actor_type,
auth.actor_id,
"event.occurrence.delete",
"event",
str(event_id),
"success",
{"occurrence_key": normalized_occurrence_key},
)
except sqlite3.IntegrityError:
conn.close()
# Idempotent behavior: deleting an already-excepted occurrence is still success.
self.send_response(204)
self.end_headers()
return
conn.close()
self.send_response(204)
self.end_headers()
return
self._not_found()
def _expand_event(
self,
evt: Dict[str, Any],
window_start: datetime,
window_end: datetime,
deleted_occurrence_keys: set,
) -> List[Dict[str, Any]]:
deleted_canonical = set()
for key in deleted_occurrence_keys:
deleted_canonical.add(str(key))
canonical = _canonical_occurrence_key(str(key))
if canonical:
deleted_canonical.add(canonical)
def is_deleted(occ_start: datetime) -> bool:
raw = occ_start.isoformat()
if raw in deleted_canonical:
return True
canonical = _canonical_occurrence_key(raw)
return canonical in deleted_canonical if canonical else False
start = _parse_dt_maybe_date(evt["start_datetime"])
end = _parse_dt_maybe_date(evt["end_datetime"])
duration = end - start
repeat_type = evt.get("repeat_type", "none")
interval = max(1, int(evt.get("repeat_interval") or 1))
repeat_mode = evt.get("repeat_range_mode") or "none"
max_count: Optional[int] = None
until_dt: Optional[datetime] = None
if repeat_mode == "count" and evt.get("repeat_count"):
max_count = int(evt["repeat_count"])
if repeat_mode == "until" and evt.get("repeat_until"):
try:
until_dt = _parse_dt_maybe_date(f"{evt['repeat_until']}T23:59:59+00:00")
except ValueError:
until_dt = None
def make_occurrence(occ_start: datetime) -> Dict[str, Any]:
occ_end = occ_start + duration
return {
"event_id": evt["id"],
"uid": evt["uid"],
"title": evt["title"],
"description": evt["description"],
"location": evt["location"],
"category": evt["category"],
"all_day_event": bool(evt.get("all_day_event")),
"occurrence_start": occ_start.isoformat(),
"occurrence_end": occ_end.isoformat(),
"repeat_type": repeat_type,
}
occurrences: List[Dict[str, Any]] = []
if repeat_type == "none":
if _occurrence_overlap(start, end, window_start, window_end):
if not is_deleted(start):
occurrences.append(make_occurrence(start))
return occurrences
current = start
produced = 0
for _ in range(0, 512):
if max_count is not None and produced >= max_count:
break
if until_dt is not None and current > until_dt:
break
occ_end = current + duration
if _occurrence_overlap(current, occ_end, window_start, window_end):
if not is_deleted(current):
occurrences.append(make_occurrence(current))
if current > window_end + timedelta(days=400):
break
produced += 1
if repeat_type == "daily":
current = current + timedelta(days=interval)
elif repeat_type in {"weekly", "custom"}:
current = current + timedelta(weeks=interval)
elif repeat_type == "monthly":
nth_mode = evt.get("repeat_nth_mode") or ""
if nth_mode == "day_of_month" and evt.get("repeat_nth_day"):
next_month = _add_months(current, interval)
day = int(evt["repeat_nth_day"])
day = max(1, min(day, pycalendar.monthrange(next_month.year, next_month.month)[1]))
current = next_month.replace(day=day)
elif nth_mode == "weekday_of_month" and evt.get("repeat_nth_pos") and evt.get("repeat_nth_weekday") is not None:
next_month = _add_months(current, interval)
pos = int(evt["repeat_nth_pos"])
wd = int(evt["repeat_nth_weekday"])
nth_day = _nth_weekday_of_month(next_month.year, next_month.month, wd, pos)
if nth_day is None:
current = next_month
else:
current = next_month.replace(day=nth_day)
else:
current = _add_months(current, interval)
elif repeat_type == "yearly":
current = _add_years(current, interval)
else:
break
return occurrences
def _handle_ics_get(self) -> None:
conn = ensure_db()
ics_mode = get_setting(conn, "ics_access_mode", "public_read")
if ics_mode == "authenticated_read":
auth = self._authenticate_caldav_user() or self._authenticate_wp_admin()
if not auth:
conn.close()
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="calendar-ics-fixture"')
self.end_headers()
return
rows = conn.execute(
"SELECT * FROM events WHERE calendar_id = ? ORDER BY id ASC", (SHARED_CALENDAR_ID,)
).fetchall()
payloads = []
for row in rows:
evt = event_to_dict(row)
ex_rows = conn.execute(
"SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence' ORDER BY occurrence_key",
(row["id"],),
).fetchall()
exdates = [r["occurrence_key"] for r in ex_rows]
payloads.append(create_ics_event(evt, exdates))
conn.close()
body = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Calendar WP Plugin Fixture//EN\r\n"
"CALSCALE:GREGORIAN\r\n"
f"X-WR-TIMEZONE:{DEFAULT_TIMEZONE}\r\n"
+ ("\r\n".join(payloads) + "\r\n" if payloads else "")
+ "END:VCALENDAR\r\n"
)
encoded = body.encode("utf-8")
etag = mk_etag(body)
self.send_response(200)
self.send_header("Content-Type", "text/calendar; charset=utf-8")
self.send_header("ETag", etag)
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _event_as_ics(self, evt: Dict[str, Any]) -> str:
conn = ensure_db()
ex_rows = conn.execute(
"SELECT occurrence_key FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence' ORDER BY occurrence_key",
(evt["id"],),
).fetchall()
conn.close()
exdates = [r["occurrence_key"] for r in ex_rows]
event_ics = create_ics_event(evt, exdates)
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Calendar WP Plugin Fixture//EN\r\n" + event_ics + "\r\nEND:VCALENDAR\r\n"
def _require_caldav_auth(self, write: bool = False) -> Optional[AuthContext]:
auth = self._authenticate_caldav_user()
if not auth:
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="calendar-caldav-fixture"')
self.end_headers()
return None
if write and auth.role != "caldav_write":
self.send_response(403)
self.end_headers()
return None
return auth
def _handle_caldav_get(self, path: str) -> None:
auth = self._require_caldav_auth(write=False)
if not auth:
return
if path in {"/caldav/", "/caldav/calendars/", "/caldav/calendars/public/"}:
self._write_text(200, "caldav shared public calendar\n")
return
filename = _caldav_filename_from_path(path)
if not filename:
self._not_found()
return
conn = ensure_db()
row = _caldav_lookup_event(conn, filename)
conn.close()
if not row:
self._not_found()
return
evt = event_to_dict(row)
body = self._event_as_ics(evt).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/calendar; charset=utf-8")
self.send_header("ETag", row["etag"])
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _handle_caldav_put(self, path: str) -> None:
auth = self._require_caldav_auth(write=True)
if not auth:
return
filename = _caldav_filename_from_path(path)
if not filename:
self._not_found()
return
raw = self._read_request_body_bytes().decode("utf-8", errors="replace")
parsed = self._parse_ics_payload(raw)
if parsed is None:
self.send_response(415)
self.end_headers()
return
conn = ensure_db()
row = _caldav_lookup_event(conn, filename)
now = utc_now_iso()
status = 200
existing_by_uid = None
if row is None and parsed.get("uid"):
existing_by_uid = conn.execute(
"SELECT * FROM events WHERE calendar_id = ? AND uid = ?",
(SHARED_CALENDAR_ID, parsed["uid"]),
).fetchone()
if row is None:
if existing_by_uid is not None:
row = existing_by_uid
else:
uid = parsed["uid"] or make_uid(f"caldav:{filename}:{now}")
etag = mk_etag(f"{uid}:{now}:1")
conn.execute(
"""
INSERT INTO events
(uid, title, description, location, category, all_day_event, start_datetime, end_datetime,
repeat_type, repeat_interval, repeat_nth_mode, repeat_nth_day, repeat_nth_pos, repeat_nth_weekday,
repeat_range_mode, repeat_count, repeat_until, timezone,
calendar_id, caldav_resource, etag, sync_version, last_modified_by_user_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
uid,
parsed["summary"],
parsed["description"],
parsed["location"],
"",
0,
parsed["start_datetime"],
parsed["end_datetime"],
parsed["repeat_type"],
parsed["repeat_interval"],
parsed["repeat_nth_mode"],
parsed["repeat_nth_day"],
parsed["repeat_nth_pos"],
parsed["repeat_nth_weekday"],
parsed["repeat_range_mode"],
parsed["repeat_count"],
parsed["repeat_until"],
DEFAULT_TIMEZONE,
SHARED_CALENDAR_ID,
filename,
etag,
1,
int(auth.actor_id),
now,
now,
),
)
status = 201
conn.commit()
row = conn.execute("SELECT * FROM events WHERE id = last_insert_rowid()").fetchone()
if row is not None and parsed.get("exdates_specified"):
_sync_deleted_occurrences(
conn,
int(row["id"]),
list(parsed.get("exdates") or []),
True,
now,
)
conn.commit()
if row is not None and status != 201:
if_match = self.headers.get("If-Match")
if if_match and if_match != row["etag"]:
conn.close()
self.send_response(412)
self.end_headers()
return
sync_version = int(row["sync_version"]) + 1
etag = mk_etag(f"{row['uid']}:{now}:{sync_version}")
conn.execute(
"""
UPDATE events SET title = ?, description = ?, location = ?, start_datetime = ?, end_datetime = ?,
repeat_type = ?, repeat_interval = ?, repeat_nth_mode = ?, repeat_nth_day = ?, repeat_nth_pos = ?, repeat_nth_weekday = ?,
repeat_range_mode = ?, repeat_count = ?, repeat_until = ?,
caldav_resource = ?, etag = ?, sync_version = ?, last_modified_by_user_id = ?, updated_at = ?
WHERE id = ?
""",
(
parsed["summary"],
parsed["description"],
parsed["location"],
parsed["start_datetime"],
parsed["end_datetime"],
parsed["repeat_type"],
parsed["repeat_interval"],
parsed["repeat_nth_mode"],
parsed["repeat_nth_day"],
parsed["repeat_nth_pos"],
parsed["repeat_nth_weekday"],
parsed["repeat_range_mode"],
parsed["repeat_count"],
parsed["repeat_until"],
filename,
etag,
sync_version,
int(auth.actor_id),
now,
int(row["id"]),
),
)
if parsed.get("exdates_specified"):
_sync_deleted_occurrences(
conn,
int(row["id"]),
list(parsed.get("exdates") or []),
True,
now,
)
conn.commit()
updated = conn.execute("SELECT id, etag FROM events WHERE id = ?", (int(row["id"]),)).fetchone()
log_audit(conn, auth.actor_type, auth.actor_id, "caldav.put", "event", str(updated["id"]), "success", {"resource": filename})
conn.close()
self.send_response(status)
self.send_header("ETag", updated["etag"])
self.end_headers()
return
def _handle_caldav_delete(self, path: str) -> None:
auth = self._require_caldav_auth(write=True)
if not auth:
return
filename = _caldav_filename_from_path(path)
if not filename:
self._not_found()
return
conn = ensure_db()
row = _caldav_lookup_event(conn, filename)
if not row:
conn.close()
self._not_found()
return
event_id = int(row["id"])
conn.execute("DELETE FROM recurrence_exceptions WHERE event_id = ?", (event_id,))
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
conn.commit()
log_audit(conn, auth.actor_type, auth.actor_id, "caldav.delete", "event", str(event_id), "success", {"resource": filename})
conn.close()
self.send_response(204)
self.end_headers()
def _parse_ics_payload(self, raw: str) -> Optional[Dict[str, Any]]:
vevents = _extract_vevents(raw)
vevent = _select_master_vevent(vevents)
if not vevent:
return None
summary = _extract_line(vevent, "SUMMARY:") or "Untitled"
description = _extract_line(vevent, "DESCRIPTION:") or ""
location = _extract_line(vevent, "LOCATION:") or ""
uid = _extract_line(vevent, "UID:")
dtstart = _extract_dt(vevent, "DTSTART")
dtend = _extract_dt(vevent, "DTEND")
if not dtstart or not dtend:
return None
rrule = _extract_line(vevent, "RRULE:")
exdates = _extract_exdates(vevent)
cancelled_occurrence_exdates = _extract_cancelled_recurrence_ids(vevents)
if cancelled_occurrence_exdates:
dedup = set(exdates)
for ex in cancelled_occurrence_exdates:
if ex not in dedup:
exdates.append(ex)
dedup.add(ex)
repeat_type = "none"
repeat_interval = 1
repeat_nth_mode = ""
repeat_nth_day = None
repeat_nth_pos = None
repeat_nth_weekday = None
repeat_range_mode = "none"
repeat_count = None
repeat_until = None
if rrule:
parts = {p.split("=", 1)[0]: p.split("=", 1)[1] for p in rrule.split(";") if "=" in p}
freq = parts.get("FREQ", "")
freq_map = {"DAILY": "daily", "WEEKLY": "weekly", "MONTHLY": "monthly", "YEARLY": "yearly"}
repeat_type = freq_map.get(freq, "custom")
if "INTERVAL" in parts:
try:
repeat_interval = max(1, int(parts["INTERVAL"]))
except ValueError:
repeat_interval = 1
if repeat_type == "monthly":
bymonthday = parts.get("BYMONTHDAY")
byday = parts.get("BYDAY", "")
bysetpos = parts.get("BYSETPOS")
if bymonthday:
raw_day = bymonthday.split(",")[0].strip()
try:
parsed_day = int(raw_day)
except ValueError:
parsed_day = None
if parsed_day is not None and 1 <= parsed_day <= 31:
repeat_nth_mode = "day_of_month"
repeat_nth_day = parsed_day
elif byday:
day_map = {"SU": 0, "MO": 1, "TU": 2, "WE": 3, "TH": 4, "FR": 5, "SA": 6}
token = byday.split(",")[0].strip().upper()
m = re.fullmatch(r"([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)", token)
if m:
wd = day_map[m.group(2)]
pos_raw = m.group(1)
if not pos_raw and bysetpos:
pos_raw = bysetpos.split(",")[0].strip()
try:
pos = int(pos_raw) if pos_raw else None
except (TypeError, ValueError):
pos = None
if pos is not None and (1 <= pos <= 5 or pos == -1):
repeat_nth_mode = "weekday_of_month"
repeat_nth_pos = pos
repeat_nth_weekday = wd
if "COUNT" in parts:
repeat_range_mode = "count"
repeat_count = int(parts["COUNT"])
elif "UNTIL" in parts:
repeat_range_mode = "until"
until_raw = parts["UNTIL"].strip()
if len(until_raw) >= 8:
repeat_until = f"{until_raw[0:4]}-{until_raw[4:6]}-{until_raw[6:8]}"
else:
repeat_range_mode = "no_end"
dtstart, dtend = _normalize_monthly_anchor(
dtstart,
dtend,
repeat_type,
repeat_nth_mode,
repeat_nth_day,
repeat_nth_pos,
repeat_nth_weekday,
)
return {
"uid": uid,
"summary": summary,
"description": description,
"location": location,
"start_datetime": dtstart,
"end_datetime": dtend,
"repeat_type": repeat_type,
"repeat_interval": repeat_interval,
"repeat_nth_mode": repeat_nth_mode,
"repeat_nth_day": repeat_nth_day,
"repeat_nth_pos": repeat_nth_pos,
"repeat_nth_weekday": repeat_nth_weekday,
"repeat_range_mode": repeat_range_mode,
"repeat_count": repeat_count,
"repeat_until": repeat_until,
"exdates": exdates,
"exdates_specified": bool(_extract_prop_values(vevent, "EXDATE") or cancelled_occurrence_exdates),
}
def log_message(self, fmt: str, *args: Any) -> None:
# Keep output concise for fixture runs.
print(f"[fixture] {self.address_string()} - {fmt % args}")
def _extract_line(raw: str, prefix: str) -> Optional[str]:
for line in raw.splitlines():
if line.startswith(prefix):
return line[len(prefix) :].strip()
return None
def _extract_prop_values(raw: str, name: str) -> List[str]:
out: List[str] = []
pattern = re.compile(rf"^{re.escape(name)}(?:;[^:]+)?:([^\r\n]+)$", re.IGNORECASE)
for line in raw.splitlines():
m = pattern.match(line.strip())
if m:
out.append(m.group(1).strip())
return out
def _extract_first_vevent(raw: str) -> Optional[str]:
vevents = _extract_vevents(raw)
return vevents[0] if vevents else None
def _extract_vevents(raw: str) -> List[str]:
out: List[str] = []
for m in re.finditer(r"BEGIN:VEVENT\r?\n(.*?)\r?\nEND:VEVENT", raw, re.DOTALL):
out.append("BEGIN:VEVENT\n" + m.group(1) + "\nEND:VEVENT")
return out
def _is_cancelled_vevent(vevent: str) -> bool:
status = (_extract_line(vevent, "STATUS:") or "").strip().upper()
return status == "CANCELLED"
def _select_master_vevent(vevents: List[str]) -> str:
if not vevents:
return ""
# Prefer the true series master, not detached/cancelled overrides.
for v in vevents:
if not _extract_prop_values(v, "RECURRENCE-ID") and not _is_cancelled_vevent(v):
return v
for v in vevents:
if not _extract_prop_values(v, "RECURRENCE-ID"):
return v
for v in vevents:
if _extract_line(v, "RRULE:") and not _is_cancelled_vevent(v):
return v
for v in vevents:
if not _is_cancelled_vevent(v):
return v
return vevents[0]
def _parse_ics_token_to_iso(value: str) -> Optional[str]:
token = (value or "").strip()
if not token:
return None
# DATE value
if "T" not in token:
if re.fullmatch(r"\d{8}", token):
return f"{token[0:4]}-{token[4:6]}-{token[6:8]}T00:00:00+00:00"
return None
# DATE-TIME value
if token.endswith("Z"):
core = token[:-1]
try:
dt = datetime.strptime(core, "%Y%m%dT%H%M%S").replace(tzinfo=timezone.utc)
return dt.isoformat()
except ValueError:
return None
try:
dt = datetime.strptime(token[:15], "%Y%m%dT%H%M%S")
return dt.strftime("%Y-%m-%dT%H:%M:%S+01:00")
except ValueError:
return None
def _extract_exdates(raw: str) -> List[str]:
values = _extract_prop_values(raw, "EXDATE")
out: List[str] = []
for value in values:
for token in value.split(","):
iso = _parse_ics_token_to_iso(token)
if iso:
out.append(iso)
return out
def _extract_cancelled_recurrence_ids(vevents: List[str]) -> List[str]:
out: List[str] = []
for vevent in vevents:
rec_values = _extract_prop_values(vevent, "RECURRENCE-ID")
if not rec_values:
continue
if not _is_cancelled_vevent(vevent):
continue
for value in rec_values:
for token in value.split(","):
iso = _parse_ics_token_to_iso(token)
if iso:
out.append(iso)
return out
def _extract_dt(raw: str, field: str) -> Optional[str]:
pattern = re.compile(rf"^{field}(?:;[^:]+)?:([0-9TzZ]+)$")
for line in raw.splitlines():
m = pattern.match(line.strip())
if m:
value = m.group(1)
if "T" in value:
# Interpret as local naive fixture time and inject +01:00 offset for test determinism.
try:
dt = datetime.strptime(value[:15], "%Y%m%dT%H%M%S")
return dt.strftime("%Y-%m-%dT%H:%M:%S+01:00")
except ValueError:
return None
return None
def _sync_deleted_occurrences(
conn: sqlite3.Connection,
event_id: int,
exdates: List[str],
replace: bool,
now: str,
) -> None:
if replace:
conn.execute(
"DELETE FROM recurrence_exceptions WHERE event_id = ? AND exception_type = 'deleted_occurrence'",
(event_id,),
)
if not exdates:
return
for ex in exdates:
canonical = _canonical_occurrence_key(ex)
if not canonical:
continue
conn.execute(
"""
INSERT OR IGNORE INTO recurrence_exceptions
(event_id, occurrence_key, exception_type, override_payload, created_at, updated_at)
VALUES (?, ?, 'deleted_occurrence', NULL, ?, ?)
""",
(event_id, canonical, now, now),
)
def _xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def _html_escape(value: str) -> str:
return _xml_escape(value)
def _caldav_filename_from_path(path: str) -> Optional[str]:
m = re.fullmatch(r"/caldav/calendars/public/([^/]+\.ics)", path)
if not m:
return None
return unquote(m.group(1))
def _caldav_lookup_event(conn: sqlite3.Connection, filename: str) -> Optional[sqlite3.Row]:
return conn.execute(
"SELECT * FROM events WHERE calendar_id = ? AND caldav_resource = ?",
(SHARED_CALENDAR_ID, filename),
).fetchone()
def cmd_init(_: argparse.Namespace) -> int:
conn = ensure_db()
create_schema(conn)
conn.close()
print(f"initialized fixture db at {DB_PATH}")
return 0
def cmd_reset(_: argparse.Namespace) -> int:
conn = ensure_db()
drop_all(conn)
create_schema(conn)
conn.close()
print(f"reset fixture db at {DB_PATH}")
return 0
def cmd_seed(_: argparse.Namespace) -> int:
conn = ensure_db()
create_schema(conn)
seed_data(conn)
conn.close()
print("seeded fixture data")
return 0
def cmd_run(args: argparse.Namespace) -> int:
conn = ensure_db()
create_schema(conn)
conn.close()
server = ThreadingHTTPServer((args.host, args.port), FixtureHandler)
trace_log = fixture_trace_log_path()
trace_log.parent.mkdir(parents=True, exist_ok=True)
print(f"fixture server listening at http://{args.host}:{args.port}")
print(f"http trace log: {trace_log}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Calendar plugin local fixture server")
sub = parser.add_subparsers(dest="command", required=True)
p_init = sub.add_parser("init", help="initialize fixture db schema")
p_init.set_defaults(func=cmd_init)
p_reset = sub.add_parser("reset", help="reset fixture db schema")
p_reset.set_defaults(func=cmd_reset)
p_seed = sub.add_parser("seed", help="seed fixture db data")
p_seed.set_defaults(func=cmd_seed)
p_run = sub.add_parser("run", help="run fixture server")
p_run.add_argument("--host", default="127.0.0.1")
p_run.add_argument("--port", type=int, default=8080)
p_run.set_defaults(func=cmd_run)
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())