This commit is contained in:
Adrian Stephens 2026-04-03 13:00:03 +01:00
parent 1b803f077b
commit 91fd6c2f43
31 changed files with 482 additions and 100 deletions

View File

@ -1,11 +1,13 @@
# Calendar plugin for wordpress site
Status as of 2026-03-31 when codex credit ran out:
1. Removal of plugin did not work. Need to set all files owner to www-data.
2. Deletion of event in ui doesn't delete event in thunderbird
3. Cannot subscribe to an empty calendar in thunderbird
4. Click inside month cell doesn't add event.
5. Login pane hidden under website hero/banner image
1. Removal of plugin did not work. Need to set all files owner to www-data. - done
2. Deletion of event in ui doesn't delete event in thunderbird - done
3. Cannot subscribe to an empty calendar in thunderbird - ok
4. Click inside month cell doesn't add event. - cannot reproduce
5. Login pane hidden under website hero/banner image - done
6. Updating an events description via caldav creates weird sequences, e.g.
a space ends up as: text/html,%C2%A0":
a space ends up as: text/html,%C2%A0":
7. In a private window, Click on event from not-logged-in calendar page shows event as a dialog box, it should show it as a panel with a subset of the edit event panel - i.e. the title, category, location, start and end and description. -
8.

View File

@ -3,7 +3,7 @@
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Version: 1.0.1
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)

View File

@ -346,7 +346,7 @@ final class IcsService
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
[$left, $value] = $this->splitContentLine($line);
if ($left === '') {
continue;
}
@ -364,6 +364,23 @@ final class IcsService
return $in ? $props : null;
}
private function splitContentLine(string $line): array
{
$inQuotes = false;
$len = strlen($line);
for ($i = 0; $i < $len; $i++) {
$ch = $line[$i];
if ($ch === '"') {
$inQuotes = !$inQuotes;
continue;
}
if ($ch === ':' && !$inQuotes) {
return [substr($line, 0, $i), substr($line, $i + 1)];
}
}
return [$line, ''];
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);

View File

@ -180,10 +180,10 @@ final class Plugin
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
<div id="cp-view-panel"></div>
<ul id="cp-public-list" style="margin-top:8px;"></ul>
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;align-items:center;justify-content:center;padding:12px;box-sizing:border-box;">
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
<h3 style="margin:0 0 8px 0;">Account Login</h3>
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
@ -203,7 +203,7 @@ final class Plugin
</div>
</div>
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
<input id="cp-event-id" type="hidden" />
@ -252,7 +252,7 @@ final class Plugin
</div>
</div>
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
<h3 style="margin:0 0 8px 0;">Event Details</h3>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
@ -287,6 +287,35 @@ final class Plugin
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
const localYmd=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate());};
const dmy=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return p(d.getDate())+"/"+p(d.getMonth()+1)+"/"+d.getFullYear();};
const longDate=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return ymd(iso);}
return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric"});
};
const timeValue=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return "";}
let h=d.getHours();
const m=d.getMinutes();
const mer=h>=12?"pm":"am";
h=h%12;
if(h===0){h=12;}
if(m===0){return `${h}${mer}`;}
return `${h}.${String(m).padStart(2,"0")}${mer}`;
};
const timeRange=(startIso,endIso)=>{
const s=new Date(startIso);
const e=new Date(endIso);
if(Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())){return "";}
const sm=s.getHours()>=12?"pm":"am";
const em=e.getHours()>=12?"pm":"am";
let sv=timeValue(startIso);
const ev=timeValue(endIso);
if(sm===em){sv=sv.replace(/(am|pm)$/,"");}
return `${sv}${ev}`;
};
const itemId=(it)=>String(it.event_id||it.id||"");
const futureWrap=()=>s("cp-future-wrap");
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
@ -632,23 +661,23 @@ final class Plugin
const rowsForDay=(items)=>{
const t=theme();
return items.map(it=>`<div data-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
return items.map(it=>`<div data-cp-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
};
const bindClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
el.addEventListener("click",async()=>{
const id=el.getAttribute("data-id")||"";
const id=el.getAttribute("data-cp-id")||"";
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
if(it){await openEditorForItem(it);}
});
});
};
const bindCreateClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
el.addEventListener("click",(ev)=>{
if(ev.target && ev.target.closest("[data-id]")){return;}
const dateYmd=el.getAttribute("data-create-date")||"";
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
const dateYmd=el.getAttribute("data-cp-create-date")||"";
if(dateYmd){openEditorCreateAt(dateYmd);}
});
});
@ -668,14 +697,19 @@ final class Plugin
panel.innerHTML="";
(items||[]).slice(0,300).forEach(it=>{
const li=document.createElement("li");
li.setAttribute("data-id", itemId(it));
li.setAttribute("data-cp-id", itemId(it));
li.style.cursor="pointer";
li.style.background=t.cellBg;
li.style.border=`1px solid ${t.border}`;
li.style.margin="4px 0";
li.style.padding="6px 8px";
li.style.borderRadius="4px";
li.textContent=ymd(it.occurrence_start)+" "+(it.all_day_event?"All-day":(hm(it.occurrence_start)+""+hm(it.occurrence_end)))+" "+(it.description||it.title||"");
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
const timeLabel=it.all_day_event?"All day":timeRange(it.occurrence_start,it.occurrence_end);
const title=it.title||"";
const desc=String(it.description||"").trim();
const headline=[dateLabel,timeLabel,title].filter(Boolean).join(", ");
li.innerHTML=`<div>${esc(headline)}</div>${(desc!=="" && desc!==title)?`<div style="color:${t.mutedText};margin-top:2px;">${esc(desc)}</div>`:""}`;
ul.appendChild(li);
});
bindClicks();
@ -708,7 +742,7 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
cells+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
cells+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
}
panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr><th style="width:65px;border:1px solid ${t.border};background:${t.headBg};">Time</th>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr><tr><td style="border:1px solid ${t.border};vertical-align:top;padding:4px;background:${t.mutedBg};color:${t.mutedText};">00:00<br>06:00<br>12:00<br>18:00</td>${cells}</tr></table>`;
bindClicks();
@ -732,7 +766,7 @@ final class Plugin
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
const inMonth=d.getMonth()===anchor.getMonth();
if(inMonth){rowHasInMonth=true;}
tds+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
tds+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
}
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
}
@ -757,15 +791,15 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const same=d.getMonth()===m;
g+=`<div data-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
g+=`<div data-cp-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
}
out+=`<div style="border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${mon[m]}</strong></div><div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;">${g}</div></div>`;
}
out+='</div>';
panel.innerHTML=out;
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
el.addEventListener("click",()=>{
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
s("cp-view").value="week";
loadPublic();
});
@ -785,7 +819,7 @@ final class Plugin
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
s("cp-logout-btn").onclick=async()=>{
await api("/users/logout",{method:"POST"});
@ -932,7 +966,7 @@ final class Plugin
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
if(tokenFromUrl){
s("cp-verify-token").value=tokenFromUrl;
s("cp-auth-modal").style.display="block";
s("cp-auth-modal").style.display="flex";
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
}
@ -979,18 +1013,19 @@ HTML
}
$rows = [];
$sidebarTz = new \DateTimeZone('Europe/London');
foreach ($items as $item) {
$start = (string) ($item['occurrence_start'] ?? '');
$end = (string) ($item['occurrence_end'] ?? '');
$startTs = strtotime($start);
$endTs = strtotime($end);
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
$timeLabel = '';
if ($startTs !== false && $endTs !== false) {
$startTime = date('H:i', $startTs);
$endTime = date('H:i', $endTs);
if ($startDt !== null && $endDt !== null) {
$startTime = $startDt->format('H:i');
$endTime = $endDt->format('H:i');
if ($startTime !== '00:00' || $endTime !== '00:00') {
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
}
}
$title = trim((string) ($item['title'] ?? ''));
@ -1010,12 +1045,24 @@ HTML
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
}
private function formatSidebarTimeRange(int $startTs, int $endTs): string
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
{
$startMeridiem = strtolower(date('a', $startTs));
$endMeridiem = strtolower(date('a', $endTs));
$startLabel = $this->formatSidebarTimeValue($startTs);
$endLabel = $this->formatSidebarTimeValue($endTs);
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->setTimezone($timezone);
} catch (\Throwable) {
return null;
}
}
private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
{
$startMeridiem = strtolower($startDt->format('a'));
$endMeridiem = strtolower($endDt->format('a'));
$startLabel = $this->formatSidebarTimeValue($startDt);
$endLabel = $this->formatSidebarTimeValue($endDt);
if ($startMeridiem === $endMeridiem) {
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
return $startLabel . '' . $endLabel;
@ -1023,11 +1070,11 @@ HTML
return $startLabel . '' . $endLabel;
}
private function formatSidebarTimeValue(int $ts): string
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
{
$hour = (int) date('G', $ts);
$minute = (int) date('i', $ts);
$meridiem = strtolower(date('a', $ts));
$hour = (int) $dt->format('G');
$minute = (int) $dt->format('i');
$meridiem = strtolower($dt->format('a'));
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
@ -1375,7 +1422,7 @@ HTML
return [
'status' => 'ok',
'plugin' => 'calendar-plugin',
'version' => '0.1.15',
'version' => '1.0.1',
'db_prefix' => $this->db->getPrefix(),
];
},

View File

@ -15,6 +15,6 @@ e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastr
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
b0e95460e705b9059bf2f197c17f17c51c9bd5492ca69eaa073ebbb808542fe0 ./src/Plugin.php
7c740beff8c22271e1d3e578368c57b9d4b9ebdc42852f0fc967441d39c87d7b ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
af5fe54d7e0ded93bddd52890eb2d7f78ae378e3c8008dafabb527d152336f5d ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
8af4c2cb42b8ec24b0cce507d9e0911063c8a4caa5023216125fdc5988bc5eaa ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
9557a73776dcd4d43288d6415589555eaa537eee22d13592678699bc4cd02efc ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
83e082e7afd6504c89241a11a2fcfdbd685985083a932fa30bbfc2df7518c3c7 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
a3e0e5bbbf679c4f2f98de78149fa56365f29f4b170b8d2d7e8cafa98a730e43 ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
db3af8bbd3c7bb7a84e2309ea5ba5c350cb02c72ac4849778d6c219686c36d8f ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
c1a836ec784d11ede886a399b128ed803c0fbaec60458e1fb6b72f6223745ee8 ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
3f3dd111db8a73fc2dfb8e8ec23c04cc15b805d0e9fe3006a63f4e656a80ff1d ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
67c918ffb3c537d09017ee1c0650d4007ec376f2b75632bb366e5f370b3671a3 ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
598ef126274bf5b19bf47e18c617f26dfa95639f73602d5113e3a50ef12359c1 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
93108c80fea01a4d1de36fbf3b02148c060051ef4c6ac85e34076061e9b222ac ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
b9d407b4415d3fd1db145b9ba0b0f950e2993a72828783caf94d0d537fe077ff ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
042b7c45db8136ba535ea1d892a02826fb75a8607fc7f07984b9f7692dc47040 ./calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./src/Domain/IcsService.php
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
d268d1b18384c399bd5cd5d31f96b26d271c559d656ef447428594f76078ff46 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -3,7 +3,7 @@
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Version: 1.0.1
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)

View File

@ -346,7 +346,7 @@ final class IcsService
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
[$left, $value] = $this->splitContentLine($line);
if ($left === '') {
continue;
}
@ -364,6 +364,23 @@ final class IcsService
return $in ? $props : null;
}
private function splitContentLine(string $line): array
{
$inQuotes = false;
$len = strlen($line);
for ($i = 0; $i < $len; $i++) {
$ch = $line[$i];
if ($ch === '"') {
$inQuotes = !$inQuotes;
continue;
}
if ($ch === ':' && !$inQuotes) {
return [substr($line, 0, $i), substr($line, $i + 1)];
}
}
return [$line, ''];
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);

View File

@ -180,10 +180,10 @@ final class Plugin
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
<div id="cp-view-panel"></div>
<ul id="cp-public-list" style="margin-top:8px;"></ul>
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;align-items:center;justify-content:center;padding:12px;box-sizing:border-box;">
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
<h3 style="margin:0 0 8px 0;">Account Login</h3>
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
@ -203,7 +203,7 @@ final class Plugin
</div>
</div>
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
<input id="cp-event-id" type="hidden" />
@ -252,7 +252,7 @@ final class Plugin
</div>
</div>
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
<h3 style="margin:0 0 8px 0;">Event Details</h3>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
@ -287,6 +287,35 @@ final class Plugin
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
const localYmd=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate());};
const dmy=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return p(d.getDate())+"/"+p(d.getMonth()+1)+"/"+d.getFullYear();};
const longDate=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return ymd(iso);}
return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric"});
};
const timeValue=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return "";}
let h=d.getHours();
const m=d.getMinutes();
const mer=h>=12?"pm":"am";
h=h%12;
if(h===0){h=12;}
if(m===0){return `${h}${mer}`;}
return `${h}.${String(m).padStart(2,"0")}${mer}`;
};
const timeRange=(startIso,endIso)=>{
const s=new Date(startIso);
const e=new Date(endIso);
if(Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())){return "";}
const sm=s.getHours()>=12?"pm":"am";
const em=e.getHours()>=12?"pm":"am";
let sv=timeValue(startIso);
const ev=timeValue(endIso);
if(sm===em){sv=sv.replace(/(am|pm)$/,"");}
return `${sv}${ev}`;
};
const itemId=(it)=>String(it.event_id||it.id||"");
const futureWrap=()=>s("cp-future-wrap");
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
@ -632,23 +661,23 @@ final class Plugin
const rowsForDay=(items)=>{
const t=theme();
return items.map(it=>`<div data-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
return items.map(it=>`<div data-cp-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
};
const bindClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
el.addEventListener("click",async()=>{
const id=el.getAttribute("data-id")||"";
const id=el.getAttribute("data-cp-id")||"";
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
if(it){await openEditorForItem(it);}
});
});
};
const bindCreateClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
el.addEventListener("click",(ev)=>{
if(ev.target && ev.target.closest("[data-id]")){return;}
const dateYmd=el.getAttribute("data-create-date")||"";
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
const dateYmd=el.getAttribute("data-cp-create-date")||"";
if(dateYmd){openEditorCreateAt(dateYmd);}
});
});
@ -668,14 +697,19 @@ final class Plugin
panel.innerHTML="";
(items||[]).slice(0,300).forEach(it=>{
const li=document.createElement("li");
li.setAttribute("data-id", itemId(it));
li.setAttribute("data-cp-id", itemId(it));
li.style.cursor="pointer";
li.style.background=t.cellBg;
li.style.border=`1px solid ${t.border}`;
li.style.margin="4px 0";
li.style.padding="6px 8px";
li.style.borderRadius="4px";
li.textContent=ymd(it.occurrence_start)+" "+(it.all_day_event?"All-day":(hm(it.occurrence_start)+""+hm(it.occurrence_end)))+" "+(it.description||it.title||"");
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
const timeLabel=it.all_day_event?"All day":timeRange(it.occurrence_start,it.occurrence_end);
const title=it.title||"";
const desc=String(it.description||"").trim();
const headline=[dateLabel,timeLabel,title].filter(Boolean).join(", ");
li.innerHTML=`<div>${esc(headline)}</div>${(desc!=="" && desc!==title)?`<div style="color:${t.mutedText};margin-top:2px;">${esc(desc)}</div>`:""}`;
ul.appendChild(li);
});
bindClicks();
@ -708,7 +742,7 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
cells+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
cells+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
}
panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr><th style="width:65px;border:1px solid ${t.border};background:${t.headBg};">Time</th>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr><tr><td style="border:1px solid ${t.border};vertical-align:top;padding:4px;background:${t.mutedBg};color:${t.mutedText};">00:00<br>06:00<br>12:00<br>18:00</td>${cells}</tr></table>`;
bindClicks();
@ -732,7 +766,7 @@ final class Plugin
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
const inMonth=d.getMonth()===anchor.getMonth();
if(inMonth){rowHasInMonth=true;}
tds+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
tds+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
}
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
}
@ -757,15 +791,15 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const same=d.getMonth()===m;
g+=`<div data-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
g+=`<div data-cp-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
}
out+=`<div style="border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${mon[m]}</strong></div><div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;">${g}</div></div>`;
}
out+='</div>';
panel.innerHTML=out;
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
el.addEventListener("click",()=>{
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
s("cp-view").value="week";
loadPublic();
});
@ -785,7 +819,7 @@ final class Plugin
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
s("cp-logout-btn").onclick=async()=>{
await api("/users/logout",{method:"POST"});
@ -932,7 +966,7 @@ final class Plugin
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
if(tokenFromUrl){
s("cp-verify-token").value=tokenFromUrl;
s("cp-auth-modal").style.display="block";
s("cp-auth-modal").style.display="flex";
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
}
@ -979,18 +1013,19 @@ HTML
}
$rows = [];
$sidebarTz = new \DateTimeZone('Europe/London');
foreach ($items as $item) {
$start = (string) ($item['occurrence_start'] ?? '');
$end = (string) ($item['occurrence_end'] ?? '');
$startTs = strtotime($start);
$endTs = strtotime($end);
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
$timeLabel = '';
if ($startTs !== false && $endTs !== false) {
$startTime = date('H:i', $startTs);
$endTime = date('H:i', $endTs);
if ($startDt !== null && $endDt !== null) {
$startTime = $startDt->format('H:i');
$endTime = $endDt->format('H:i');
if ($startTime !== '00:00' || $endTime !== '00:00') {
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
}
}
$title = trim((string) ($item['title'] ?? ''));
@ -1010,12 +1045,24 @@ HTML
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
}
private function formatSidebarTimeRange(int $startTs, int $endTs): string
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
{
$startMeridiem = strtolower(date('a', $startTs));
$endMeridiem = strtolower(date('a', $endTs));
$startLabel = $this->formatSidebarTimeValue($startTs);
$endLabel = $this->formatSidebarTimeValue($endTs);
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->setTimezone($timezone);
} catch (\Throwable) {
return null;
}
}
private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
{
$startMeridiem = strtolower($startDt->format('a'));
$endMeridiem = strtolower($endDt->format('a'));
$startLabel = $this->formatSidebarTimeValue($startDt);
$endLabel = $this->formatSidebarTimeValue($endDt);
if ($startMeridiem === $endMeridiem) {
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
return $startLabel . '' . $endLabel;
@ -1023,11 +1070,11 @@ HTML
return $startLabel . '' . $endLabel;
}
private function formatSidebarTimeValue(int $ts): string
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
{
$hour = (int) date('G', $ts);
$minute = (int) date('i', $ts);
$meridiem = strtolower(date('a', $ts));
$hour = (int) $dt->format('G');
$minute = (int) $dt->format('i');
$meridiem = strtolower($dt->format('a'));
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
@ -1375,7 +1422,7 @@ HTML
return [
'status' => 'ok',
'plugin' => 'calendar-plugin',
'version' => '0.1.15',
'version' => '1.0.1',
'db_prefix' => $this->db->getPrefix(),
];
},

View File

@ -104,6 +104,7 @@ Minimum mapping expectations:
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
- Recurrence rules -> `RRULE`
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
- Parsing must correctly handle quoted property parameters containing `:` (for example `DESCRIPTION;ALTREP="data:text/html,..."`) so field values are not polluted by parameter content.
Privacy visibility mapping:

View File

@ -44,11 +44,16 @@ Before deployment:
## Deployment Procedure Requirements
Required high-level procedure:
1. Transfer approved artifact to remote host staging area.
2. Extract artifact to a clean temporary directory on remote host.
3. Validate extracted plugin directory structure.
4. Synchronize extracted plugin directory to deploy directory.
5. Run post-deploy verification checks.
1. Build a fresh deploy artifact as part of deploy execution (package step) using the current repository state.
2. Transfer approved artifact to remote host staging area.
3. Extract artifact to a clean temporary directory on remote host.
4. Validate extracted plugin directory structure.
5. Synchronize extracted plugin directory to deploy directory.
6. Run post-deploy verification checks.
Additional policy requirement:
- Deploy flow should not bypass packaging by deploying an arbitrary stale artifact path; deployment must use the freshly built artifact for that deploy run.
- Deployment tooling may support an explicit version override for controlled releases (for example `1.0.0`); when used, that explicit version must be the packaged and deployed artifact version for that run.
## Exact-Match Validation (Required)
After deployment, deployed plugin files must exactly match the approved artifact contents (excluding allowed mutable runtime files if any are explicitly listed).

View File

@ -61,8 +61,10 @@ Required steps:
1. Create/clean a staging folder under `package/`.
2. Copy approved runtime files from `code/` into staging.
3. Apply exclusion rules to remove non-runtime artifacts.
4. Create a versioned zip archive in `package/`.
5. Record artifact name and version in release notes/changelog.
4. Increment plugin patch version (`X.Y.Z -> X.Y.(Z+1)`) for each package build unless an explicit version override is provided.
5. Keep runtime-visible version fields synchronized for the package (plugin header version and health/API version metadata).
6. Create a versioned zip archive in `package/`.
7. Record artifact name and version in release notes/changelog.
Artifact naming requirement:

View File

@ -83,6 +83,15 @@ Minimum display requirements:
- Category (if provided)
- Description excerpt/summary (if configured for display)
List-view formatting requirements:
- In `list` view, each row headline must present date, time range (or all-day marker), then title in readable natural-language order.
- Date formatting in `list` view should use long-form style (for example `5 April 2026`) rather than compact numeric-only format.
- Time range formatting in `list` view should be compact and human-readable (for example `910am`).
- Title must be sourced from event title data and must not be replaced by description text.
- If description is shown in `list` view, it should appear as secondary text below the headline.
- `list` view rows must not display default browser list bullets.
Privacy display rules:
- Public events render full details per normal display rules.
@ -119,6 +128,9 @@ Requirements:
- Single-occurrence selection must be user-friendly via a date list or compact calendar selection.
- The compact selection mode should support a 3-month grid with previous/next navigation.
- The compact selection grid must remain readable in modal layout.
- Login, Event Details, and Event Editor overlays must render above site/theme chrome (for example header/banner artwork) and remain interactable without requiring page scroll workarounds.
- Login overlay should be centered within the viewport on desktop and mobile.
- Event click interactions must use in-page overlays and must not fall back to browser-native dialog boxes.
## Login and Access Modes
- `/calendar` must support two user modes: public (not logged in) and logged-in.
@ -131,6 +143,8 @@ Requirements:
- Login dialog must support password-reset request initiation.
- Logged-in but non-approved users remain read-only.
- Approved users can perform event CRUD.
- Event-click behavior must be consistent between normal and private/incognito browser sessions.
- In public mode, clicking an event must open the Event Details overlay (not the Event Editor overlay).
## ICS Link in Web UI
The UI must include a user-visible link to an ICS representation of calendar data.
@ -162,3 +176,5 @@ Acceptance should verify:
- Empty-state behavior is clear and user-friendly.
- ICS link is present and returns valid calendar payload.
- Privacy redaction behavior is correct in public views, sidebar, and logged-in views.
- In both normal and private/incognito sessions, clicking an event in public mode opens the same Event Details overlay.
- Login/Event Details/Event Editor overlays remain above site header/banner layers and are fully usable without scrolling to bypass theme artwork.

View File

@ -11,6 +11,7 @@ fi
ARTIFACT=""
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
VERSION_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
@ -22,17 +23,24 @@ while [[ $# -gt 0 ]]; do
WP_ROOT="${2:-}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:-}"
shift 2
;;
--help|-h)
cat <<'USAGE'
Deploy a plugin artifact to remote WordPress.
Usage:
scripts/deploy_remote.sh [--artifact /abs/or/relative/path.zip] [--wp-root /var/www/wordpress]
scripts/deploy_remote.sh [--wp-root /var/www/wordpress] [--version X.Y.Z]
Defaults:
- Artifact: latest ./package/calendar-plugin-*.zip
- Always builds a fresh package first (which auto-bumps patch version)
- Remote host settings from credentials/.env
Notes:
- Use `--version` for explicit release version packaging (for example `1.0.0`).
This script enforces ownership:
- chown -R www-data:www-data <remote plugin dir>
USAGE
@ -45,10 +53,20 @@ USAGE
esac
done
if [[ -z "${ARTIFACT}" ]]; then
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -n "${ARTIFACT}" ]]; then
echo "[deploy] --artifact is not supported; deploy always builds a fresh package with bumped patch version" >&2
exit 2
fi
if [[ -n "${VERSION_OVERRIDE}" ]]; then
echo "[deploy] packaging before deployment (explicit version ${VERSION_OVERRIDE})"
"${ROOT_DIR}/scripts/package_plugin.sh" --version "${VERSION_OVERRIDE}"
else
echo "[deploy] packaging before deployment (auto patch bump)"
"${ROOT_DIR}/scripts/package_plugin.sh"
fi
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -z "${ARTIFACT}" ]]; then
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
exit 1

View File

@ -36,15 +36,25 @@ USAGE
esac
done
if [[ -z "${VERSION}" ]]; then
VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
fi
CURRENT_VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
if [[ -z "${VERSION}" ]]; then
if [[ -z "${CURRENT_VERSION}" ]]; then
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
exit 1
fi
if [[ -z "${VERSION}" ]]; then
if [[ "${CURRENT_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
else
echo "[package] current version is not semantic (X.Y.Z): ${CURRENT_VERSION}" >&2
exit 1
fi
fi
sed -Ei "s/^ \* Version: .*/ * Version: ${VERSION}/" "${ROOT_DIR}/code/calendar-plugin.php"
sed -Ei "s/('version' => ')[^']+(',)/\1${VERSION}\2/" "${ROOT_DIR}/code/src/Plugin.php"
SLUG="calendar-plugin"
PACKAGE_DIR="${ROOT_DIR}/package"
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
@ -67,5 +77,6 @@ rsync -a --delete "${ROOT_DIR}/code/" "${STAGING_DIR}/"
find . -type f -print0 | sort -z | xargs -0 sha256sum
) > "${MANIFEST}"
echo "[package] version: ${CURRENT_VERSION} -> ${VERSION}"
echo "[package] created artifact: ${ARTIFACT}"
echo "[package] created manifest: ${MANIFEST}"

View File

@ -401,6 +401,64 @@ if [[ "${CALDAV_COLLECTION_GET}" != "200" ]]; then
record_fail "caldav collection GET expected 200 got ${CALDAV_COLLECTION_GET} (${CALDAV_COLLECTION_URL})"
fi
step "caldav DESCRIPTION ALTREP parsing regression"
ALTREP_UID="remote-caldav-altrep-$(date +%s)@calendar-plugin"
ALTREP_RESOURCE="remote-altrep-$(date +%s).ics"
TMP_ALTREP_ICS="$(mktemp)"
cat > "${TMP_ALTREP_ICS}" <<ICS
BEGIN:VCALENDAR
PRODID:-//Remote Regression//EN
VERSION:2.0
BEGIN:VEVENT
UID:${ALTREP_UID}
SUMMARY:Remote CalDAV ALTREP Regression
DTSTART;TZID=Europe/London:20260428T120000
DTEND;TZID=Europe/London:20260428T130000
DESCRIPTION;ALTREP="data:text/html,test%C2%A0":test
END:VEVENT
END:VCALENDAR
ICS
ALTREP_PUT_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"${TMP_ALTREP_ICS}" \
-o /tmp/remote_test_altrep_put.out -w '%{http_code}' \
"${CALDAV_COLLECTION_URL}${ALTREP_RESOURCE}" || true)"
rm -f "${TMP_ALTREP_ICS}"
if [[ "${ALTREP_PUT_HTTP}" != "201" && "${ALTREP_PUT_HTTP}" != "204" ]]; then
record_fail "caldav ALTREP PUT failed (http ${ALTREP_PUT_HTTP})"
else
ALTREP_EVENT_ID=""
ALTREP_LIST_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -o /tmp/remote_test_altrep_list.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
if [[ "${ALTREP_LIST_HTTP}" != "200" ]]; then
record_fail "caldav ALTREP event list fetch failed (http ${ALTREP_LIST_HTTP})"
else
ALTREP_EVENT_ID="$(python3 - "${ALTREP_UID}" <<'PY'
import json,sys
uid=sys.argv[1]
payload=json.load(open('/tmp/remote_test_altrep_list.json'))
for event in payload.get("data", []):
if event.get("uid") == uid:
print(event.get("id", ""))
break
else:
print("")
PY
)"
fi
if [[ -z "${ALTREP_EVENT_ID}" ]]; then
record_fail "caldav ALTREP event not found via API list"
else
if ! curl -fsS -u "${AUTH_USER}:${AUTH_PASS}" "${BASE_URL}/wp-json/calendar/v1/events/${ALTREP_EVENT_ID}" >/tmp/remote_test_altrep_event.json; then
record_fail "caldav ALTREP event fetch failed"
elif ! json_assert /tmp/remote_test_altrep_event.json "data.get('data', {}).get('description') == 'test'"; then
record_fail "caldav ALTREP description parse regression (expected 'test')"
fi
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
"${BASE_URL}/wp-json/calendar/v1/events/${ALTREP_EVENT_ID}" >/dev/null || true
fi
fi
step "caldav sync-collection stability"
SYNC_UID="remote-caldav-sync-del-$(date +%s)@calendar-plugin"
SYNC_CREATE_JSON=$(cat <<JSON

View File

@ -46,6 +46,7 @@ Use a minimal subset:
- `pending_approval` or unverified user: cannot authenticate.
- `active` user: can discover/read/create/update/delete one event successfully.
- Monthly ordinal recurrence must round-trip for CalDAV writes (`BYDAY=2SA` and `BYSETPOS=-1` cases).
- Thunderbird-style `DESCRIPTION;ALTREP="data:text/html,..."` updates must persist clean plain-text `DESCRIPTION` values without leaking `ALTREP` parameter content into stored descriptions.
### SMK-007 API Smoke (If API Exposed)
- Event create/list/delete basic path.