From f2d5ac4a292c3884bd4258d49752a8ebda5d5cf6 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 30 Jul 2026 02:18:30 -0400 Subject: [PATCH] Feature: Calendars are now exportable shareable so Third Party Calendar services can read them, also added recoccurence to calendar, and all day along with sperating the time fields and click in a box to add calendar event is possible --- CHANGELOG.md | 45 +++ admin/database_updates/2.5.8.php | 27 ++ admin/database_updates/2.5.9.php | 28 ++ agent/calendar.php | 147 +++++-- agent/modals/calendar/calendar_event_add.php | 58 ++- agent/modals/calendar/calendar_event_edit.php | 77 +++- agent/modals/calendar/calendar_share.php | 189 +++++++++ agent/post/event.php | 101 ++++- agent/post/event_model.php | 33 +- db.sql | 8 +- functions.php | 1 + functions/calendar.php | 371 ++++++++++++++++++ guest/guest_calendar_feed.php | 114 ++++++ js/app.js | 92 +++++ js/confirm_modal.js | 4 +- 15 files changed, 1233 insertions(+), 62 deletions(-) create mode 100644 admin/database_updates/2.5.8.php create mode 100644 admin/database_updates/2.5.9.php create mode 100644 agent/modals/calendar/calendar_share.php create mode 100644 functions/calendar.php create mode 100644 guest/guest_calendar_feed.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7b162a..1d66670a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,51 @@ This file documents all notable changes made to ITFlow. - Added an **Urgent** ticket priority. - **Multiple notes per asset**, mirroring the existing contact notes, with categorized note types (Maintenance, Repair, Configuration, Upgrade, Inspection, Note). +- Fixed the last day of a multi-day all-day event not being drawn on the calendar or published + to subscribed feeds. `event_end` holds the last day the event covers, which is what the event + modal asks for, but both FullCalendar and iCalendar treat an all-day end as exclusive. +- Selecting a day in month view creates an all-day event; selecting a time range in a week or + day view fills the time fields in and clears All day. Unchecking All day after a month-view + click reveals the time fields with the dates left in place, so a timed event can still be + created from month view. The end date follows the start date, and the end time defaults to an + hour after the start, in both cases leaving a longer span alone if one is already set. +- **Clicking empty space on the calendar creates an event there.** Clicking a day or a time + slot - or dragging across several - opens the New Event modal with the start and end already + filled in from the selection, and the All day switch set to match. Dragging out a range in a + week or day view carries the length of the selection through, and the automatic + end-time-follows-start behaviour no longer overwrites a range that was dragged out or + lengthened by hand. +- Repeating events are now marked on the calendar with a repeat icon and a hover note saying + how often they recur, and the edit modal states that saving or deleting affects every + occurrence. The delete action on a repeating event reads **Delete series** and now asks for + confirmation first — as does every other `confirm-link` inside an ajax modal, which + previously did nothing because the handler was bound only to links present at page load. +- **Repeating calendar events now work.** The Repeat field on the add and edit event modals + was present but disabled, and the stored value was never rendered. It is now selectable + (daily, weekly, monthly, yearly) and occurrences are drawn on the calendar. Monthly and + yearly series skip dates that do not exist in a given period rather than sliding into the + next month, so an event on the 31st appears only in months that have one, and a 29 February + event only in leap years. Recurrence is series-wide: editing any occurrence edits the whole + series, and individual occurrences cannot yet be moved or cancelled. Subscribed calendar + feeds publish the recurrence rule and let the subscribing client expand it. +- **Calendar events can be marked all day, and the date and time are now separate fields.** + Previously `calendar_events` had no all-day column and the calendar inferred it from a start + time of midnight, which made a genuine midnight appointment indistinguishable from an all-day + event. The add and edit event modals now carry an **All day** switch above four fields - date + from, date to, time from, time to - and the two time fields are hidden while All day is on. + All day is selected by default on a new event. Existing events are backfilled by the database + update using the old rule, so nothing in an existing calendar changes appearance. +- **Calendars can be published as a read-only subscription feed.** Any calendar can be shared as + an iCalendar (ICS) link from its menu on the Calendar page and subscribed to in Google Calendar, + Nextcloud, Apple Calendar, Thunderbird, or anything else that accepts a feed URL. The link + carries a secret key and requires no login, so it can be added to a phone or handed to a + colleague without an ITFlow account. It can be regenerated or revoked at any time, and a shared + calendar is marked with an icon in the calendar list. A **busy only** option publishes time + blocks without titles, descriptions, or locations. Feeds are read-only: events added or edited + in the subscribing client never reach ITFlow. Refresh timing belongs to the client — Google + refreshes on its own schedule, often 12-24 hours, and cannot be forced, while Nextcloud defaults + to once a week unless `calendarSubscriptionRefreshRate` is lowered, and will refuse a feed URL + that resolves to a private IP address. - **Ticket templates on recurring tickets.** A recurring ticket can now be assigned a ticket template. Picking one fills in the subject and details, and the template's task list is stamped onto every ticket the schedule raises - from the nightly cron run and from a forced run alike. diff --git a/admin/database_updates/2.5.8.php b/admin/database_updates/2.5.8.php new file mode 100644 index 00000000..e44999ba --- /dev/null +++ b/admin/database_updates/2.5.8.php @@ -0,0 +1,27 @@ +
+ + +
+
- -
-
- -
- +
+ +
-
-
-
- +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+
-
@@ -87,7 +121,7 @@
- diff --git a/agent/modals/calendar/calendar_event_edit.php b/agent/modals/calendar/calendar_event_edit.php index 8a8363d0..80409276 100644 --- a/agent/modals/calendar/calendar_event_edit.php +++ b/agent/modals/calendar/calendar_event_edit.php @@ -13,6 +13,18 @@ $event_location = escapeHtml($row['event_location']); $event_start = escapeHtml($row['event_start']); $event_end = escapeHtml($row['event_end']); $event_repeat = escapeHtml($row['event_repeat']); +$event_all_day = intval($row['event_all_day'] ?? 0); + +// Split the stored datetimes into the four fields the form now uses. An empty +// event_end previously fed strtotime('') and rendered as 1970 - fall back to the +// start instead. +$event_start_ts = strtotime($event_start) ?: time(); +$event_end_ts = !empty($row['event_end']) ? (strtotime($event_end) ?: $event_start_ts) : $event_start_ts; + +$event_start_date = date('Y-m-d', $event_start_ts); +$event_start_time = date('H:i', $event_start_ts); +$event_end_date = date('Y-m-d', $event_end_ts); +$event_end_time = date('H:i', $event_end_ts); $calendar_id = intval($row['calendar_id']); $calendar_name = escapeHtml($row['calendar_name']); $calendar_color = escapeHtml($row['calendar_color']); @@ -89,22 +101,61 @@ ob_start();
+ +
+ + This event repeats every . + Saving or deleting affects every occurrence — a single + occurrence cannot be moved or cancelled on its own. +
+ +
- -
-
- -
- +
+ > +
-
-
-
- +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+ > +
+
+
+ +
+
+ +
+ >
-
@@ -114,7 +165,7 @@ ob_start();
- @@ -190,7 +241,7 @@ ob_start();
diff --git a/agent/modals/calendar/calendar_share.php b/agent/modals/calendar/calendar_share.php new file mode 100644 index 00000000..a2aa25ab --- /dev/null +++ b/agent/modals/calendar/calendar_share.php @@ -0,0 +1,189 @@ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + + + + +
+ + + +$calendar_name published - copy the subscription link from the share dialog"); + + } else { + + mysqli_query($mysqli, "UPDATE calendars SET + calendar_feed_busy_only = $busy_only + WHERE calendar_id = $calendar_id"); + + logAudit("Calendar", "Edit", "$session_name updated feed settings for calendar $calendar_name", 0, $calendar_id); + + flashAlert("Feed settings for $calendar_name updated"); + } + + redirect(); + +} + +if (isset($_GET['regenerate_calendar_feed'])) { + + validateCSRFToken(); + + enforceAdminPermission(); + + $calendar_id = intval($_GET['regenerate_calendar_feed']); + + $calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name')); + + $feed_key = escapeSql(randomString(32)); + + mysqli_query($mysqli, "UPDATE calendars SET + calendar_feed_key = '$feed_key', + calendar_feed_created_at = NOW(), + calendar_feed_accessed_at = NULL + WHERE calendar_id = $calendar_id"); + + logAudit("Calendar", "Share", "$session_name regenerated the feed link for calendar $calendar_name", 0, $calendar_id); + + flashAlert("Feed link for $calendar_name regenerated - existing subscribers will stop updating and must re-subscribe", 'error'); + + redirect(); + +} + +if (isset($_GET['unshare_calendar'])) { + + validateCSRFToken(); + + enforceAdminPermission(); + + $calendar_id = intval($_GET['unshare_calendar']); + + $calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name')); + + mysqli_query($mysqli, "UPDATE calendars SET + calendar_feed_key = NULL, + calendar_feed_busy_only = 0, + calendar_feed_created_at = NULL, + calendar_feed_accessed_at = NULL + WHERE calendar_id = $calendar_id"); + + logAudit("Calendar", "Share", "$session_name stopped sharing calendar $calendar_name", 0, $calendar_id); + + flashAlert("Calendar $calendar_name is no longer shared", 'error'); + + redirect(); + +} + if (isset($_POST['add_event'])) { validateCSRFToken(); @@ -79,7 +176,7 @@ if (isset($_POST['add_event'])) { enforceClientAccess(); } - mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id"); + mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id"); $event_id = mysqli_insert_id($mysqli); @@ -158,7 +255,7 @@ if (isset($_POST['edit_event'])) { $event_id = intval($_POST['event_id']); - mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id"); + mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id"); //If email is checked if ($email_event == 1) { diff --git a/agent/post/event_model.php b/agent/post/event_model.php index 180cc7bb..092390e0 100644 --- a/agent/post/event_model.php +++ b/agent/post/event_model.php @@ -5,8 +5,37 @@ $calendar_id = intval($_POST['calendar']); $title = escapeSql($_POST['title']); $location = escapeSql($_POST['location']); $description = escapeSql($_POST['description']); -$start = escapeSql($_POST['start']); -$end = escapeSql($_POST['end']); +$all_day = isset($_POST['all_day']) ? 1 : 0; + +/* + * The form posts the date and the time as separate fields, so recombine them into + * the DATETIME columns. + * + * All-day events are pinned to midnight at both ends, and event_end holds the LAST + * DAY the event covers - the same thing the form asks for. Both FullCalendar and + * iCalendar treat an all-day end as exclusive, so the render and feed paths add a + * day; do not add one here as well. + */ +$start_date = $_POST['start_date'] ?? ''; +$end_date = !empty($_POST['end_date']) ? $_POST['end_date'] : $start_date; + +if ($all_day) { + $start_raw = "$start_date 00:00:00"; + $end_raw = "$end_date 00:00:00"; +} else { + $start_time = !empty($_POST['start_time']) ? $_POST['start_time'] : '00:00'; + $end_time = !empty($_POST['end_time']) ? $_POST['end_time'] : $start_time; + $start_raw = "$start_date $start_time"; + $end_raw = "$end_date $end_time"; +} + +// A malformed value would otherwise land in 1970 via strtotime() returning false +$start_ts = strtotime($start_raw) ?: time(); +$end_ts = strtotime($end_raw) ?: $start_ts; + +$start = escapeSql(date('Y-m-d H:i:s', $start_ts)); +$end = escapeSql(date('Y-m-d H:i:s', $end_ts)); + $repeat = escapeSql($_POST['repeat'] ?? 0); $client_id = intval($_POST['client_id']); $email_event = intval($_POST['email_event'] ?? 0); diff --git a/db.sql b/db.sql index 95b44ea6..6997be88 100644 --- a/db.sql +++ b/db.sql @@ -408,6 +408,7 @@ CREATE TABLE `calendar_events` ( `event_description` longtext DEFAULT NULL, `event_start` datetime NOT NULL, `event_end` datetime DEFAULT NULL, + `event_all_day` tinyint(1) NOT NULL DEFAULT 0, `event_repeat` varchar(200) DEFAULT NULL, `event_created_at` datetime NOT NULL DEFAULT current_timestamp(), `event_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), @@ -432,10 +433,15 @@ CREATE TABLE `calendars` ( `calendar_id` int(11) NOT NULL AUTO_INCREMENT, `calendar_name` varchar(200) NOT NULL, `calendar_color` varchar(200) NOT NULL, + `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL, + `calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0, + `calendar_feed_created_at` datetime DEFAULT NULL, + `calendar_feed_accessed_at` datetime DEFAULT NULL, `calendar_created_at` datetime NOT NULL DEFAULT current_timestamp(), `calendar_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `calendar_archived_at` datetime DEFAULT NULL, - PRIMARY KEY (`calendar_id`) + PRIMARY KEY (`calendar_id`), + UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/functions.php b/functions.php index 4730cb4c..e98cb6e4 100644 --- a/functions.php +++ b/functions.php @@ -24,3 +24,4 @@ require_once __DIR__ . '/functions/db.php'; require_once __DIR__ . '/functions/payments.php'; require_once __DIR__ . '/functions/sla.php'; require_once __DIR__ . '/functions/export.php'; +require_once __DIR__ . '/functions/calendar.php'; diff --git a/functions/calendar.php b/functions/calendar.php new file mode 100644 index 00000000..b3dfb0ca --- /dev/null +++ b/functions/calendar.php @@ -0,0 +1,371 @@ + 75) { + $folded .= $buffer . "\r\n"; + $buffer = ' '; + } + $buffer .= $char; + } + + return $folded . $buffer; +} + +/* + * Converts an ITFlow datetime to an iCalendar UTC DATE-TIME + * Stored datetimes are in the instance's configured timezone. Emitting UTC for + * everything means the feed needs no VTIMEZONE block at all, which removes the + * largest source of breakage in hand-written ICS. + */ +function icsFormatUtc($datetime, $timezone) { + + if (empty($datetime) || $datetime === '0000-00-00 00:00:00') { + return null; + } + + try { + $dt = new DateTime($datetime, new DateTimeZone($timezone)); + $dt->setTimezone(new DateTimeZone('UTC')); + } catch (Exception $e) { + return null; + } + + return $dt->format('Ymd\THis\Z'); +} + +/* + * Converts an ITFlow datetime to an iCalendar DATE value (all-day events) + * No timezone conversion - a floating date must stay on the day it was entered + */ +function icsFormatDate($datetime, $offset_days = 0) { + + if (empty($datetime) || $datetime === '0000-00-00 00:00:00') { + return null; + } + + $timestamp = strtotime($datetime); + + if ($timestamp === false) { + return null; + } + + return date('Ymd', strtotime("$offset_days day", $timestamp)); +} + +/* + * True when an event looks like an all-day event + * Fallback for rows written before event_all_day existed (added in 2.5.9): a + * midnight start, and either no end or a midnight end - the same rule the + * calendar page itself rendered by, and the rule the 2.5.9 backfill applies. + */ +function icsEventIsAllDay($start, $end) { + + if (empty($start) || date('H:i:s', strtotime($start)) !== '00:00:00') { + return false; + } + + if (empty($end) || $end === '0000-00-00 00:00:00') { + return true; + } + + return date('H:i:s', strtotime($end)) === '00:00:00'; +} + +/* + * Maps ITFlow's event_repeat wording onto an RRULE + * The repeat select is disabled in both event modals as of 2.5.x, so this only + * applies to rows created before it was disabled or after it is re-enabled + */ +function icsRepeatToRrule($repeat) { + + $map = [ + 'Day' => 'FREQ=DAILY', + 'Week' => 'FREQ=WEEKLY', + 'Month' => 'FREQ=MONTHLY', + 'Year' => 'FREQ=YEARLY' + ]; + + return $map[(string) $repeat] ?? null; +} + +/* + * Builds a complete VCALENDAR document for one calendar + * + * $calendar - the calendars row (name, color, feed settings) + * $events - array of calendar_events rows + * $timezone - config_timezone, used to shift stored datetimes to UTC + * $host - config_base_url, used to build stable globally-unique UIDs + * + * Returns the document with CRLF line endings, ready to send. + */ +function buildCalendarFeedIcs(array $calendar, array $events, $timezone, $host) { + + $busy_only = !empty($calendar['calendar_feed_busy_only']); + $calendar_name = (string) $calendar['calendar_name']; + $host = $host ?: 'itflow.local'; + $now_utc = gmdate('Ymd\THis\Z'); + + // X-WR-CALNAME is non-standard and clients render its value literally, so a + // TEXT-escaped comma shows up as a visible backslash in the Google Calendar + // sidebar. Strip only what would break the line structure. + $calendar_name_header = str_replace(["\r\n", "\r", "\n"], ' ', $calendar_name); + + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//ITFlow//ITFlow Calendar Feed//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . $calendar_name_header, + 'X-WR-TIMEZONE:' . $timezone, + // Nextcloud reads REFRESH-INTERVAL / X-PUBLISHED-TTL and stores it against + // the subscription - but only during a refresh run, and only if no rate is + // stored yet. The first scheduled run is still gated by its own default + // (P1D), so there is a cold start before this takes effect. Google ignores + // it entirely. + 'REFRESH-INTERVAL;VALUE=DURATION:PT15M', + 'X-PUBLISHED-TTL:PT15M' + ]; + + if (!empty($calendar['calendar_color'])) { + $lines[] = 'X-APPLE-CALENDAR-COLOR:' . $calendar['calendar_color']; + } + + foreach ($events as $event) { + + $event_id = intval($event['event_id']); + $start = $event['event_start']; + $end = $event['event_end']; + + $lines[] = 'BEGIN:VEVENT'; + + // Stable across edits and across feed regenerations, so subscribers + // update events in place instead of duplicating them + $lines[] = "UID:itflow-event-$event_id@$host"; + + // Derived from the event rather than the request clock: a feed whose bytes + // change on every fetch can never produce an ETag hit, which would defeat + // the conditional-GET path in guest_calendar_feed.php + $stamp = icsFormatUtc($event['event_updated_at'] ?: $event['event_created_at'], $timezone); + $lines[] = 'DTSTAMP:' . ($stamp ?: $now_utc); + + // event_all_day is authoritative as of database version 2.5.9; the + // heuristic remains as a fallback for a row that predates the backfill + $all_day = isset($event['event_all_day']) + ? !empty($event['event_all_day']) + : icsEventIsAllDay($start, $end); + + if ($all_day) { + + $dtstart = icsFormatDate($start); + + // event_end holds the last day the event covers, which is what the event + // modal asks for. DTEND is exclusive for DATE values, so it has to land + // one day past that. A missing or non-advancing end still falls back to a + // single day rather than rendering as zero-length. + $dtend = icsFormatDate($end, 1); + + if (empty($dtend) || $dtend <= $dtstart) { + $dtend = icsFormatDate($start, 1); + } + + $lines[] = "DTSTART;VALUE=DATE:$dtstart"; + $lines[] = "DTEND;VALUE=DATE:$dtend"; + + } else { + + $dtstart = icsFormatUtc($start, $timezone); + + // The event modals default a blank end to start + 1 hour client-side; + // match that rather than emitting an instantaneous event + $dtend = icsFormatUtc($end, $timezone); + if (empty($dtend) || $dtend <= $dtstart) { + $dtend = icsFormatUtc(date('Y-m-d H:i:s', strtotime("$start +1 hour")), $timezone); + } + + $lines[] = "DTSTART:$dtstart"; + $lines[] = "DTEND:$dtend"; + } + + $rrule = icsRepeatToRrule($event['event_repeat'] ?? ''); + if ($rrule) { + $lines[] = "RRULE:$rrule"; + } + + if ($busy_only) { + + // Titles, descriptions and locations all withheld - the feed shows + // only that the time is occupied + $lines[] = 'SUMMARY:Busy'; + $lines[] = 'TRANSP:OPAQUE'; + + } else { + + $lines[] = 'SUMMARY:' . icsEscapeText($event['event_title']); + + if (!empty($event['event_location'])) { + $lines[] = 'LOCATION:' . icsEscapeText($event['event_location']); + } + + if (!empty($event['event_description'])) { + $lines[] = 'DESCRIPTION:' . icsEscapeText($event['event_description']); + } + } + + if (!empty($event['event_created_at'])) { + $created = icsFormatUtc($event['event_created_at'], $timezone); + if ($created) { + $lines[] = "CREATED:$created"; + } + } + + // Lets well-behaved clients spot changed events without a SEQUENCE counter, + // which calendar_events has nowhere to store + if ($stamp) { + $lines[] = "LAST-MODIFIED:$stamp"; + } + + $lines[] = 'END:VEVENT'; + } + + $lines[] = 'END:VCALENDAR'; + + $output = ''; + foreach ($lines as $line) { + $output .= icsFoldLine($line) . "\r\n"; + } + + return $output; +} + +/* + * Expands a repeating event into concrete occurrences within a window + * + * ITFlow stores recurrence as a single row with event_repeat set to Day, Week, + * Month or Year - there is no interval, count or until, and no per-occurrence + * override. The calendar page needs real instances to render, because the bundled + * FullCalendar build has no rrule plugin (adding one would mean vendoring rrule.js + * as well). The ICS feed does not use this: it emits an RRULE and lets the + * subscribing client do its own expansion. + * + * Offsets are computed from the original start rather than by stepping a running + * date, so a long series cannot drift. + * + * Returns an array of ['start' => ..., 'end' => ...] datetime strings, always + * including the original occurrence. + */ +function expandRecurringEvent(array $event, $window_start, $window_end, $limit = 750) { + + $start = $event['event_start']; + $repeat = (string) ($event['event_repeat'] ?? ''); + + if (empty($start) || !icsRepeatToRrule($repeat)) { + return [['start' => $start, 'end' => $event['event_end']]]; + } + + try { + $base = new DateTime($start); + $from = new DateTime($window_start); + $to = new DateTime($window_end); + } catch (Exception $e) { + return [['start' => $start, 'end' => $event['event_end']]]; + } + + // Preserve the original duration on every occurrence + $duration = 0; + if (!empty($event['event_end']) && $event['event_end'] !== '0000-00-00 00:00:00') { + $duration = max(0, strtotime($event['event_end']) - strtotime($start)); + } + + $step = [ + 'Day' => 'day', + 'Week' => 'week', + 'Month' => 'month', + 'Year' => 'year' + ][$repeat]; + + $base_day = (int) $base->format('j'); + $occurrences = []; + $i = 0; + + // Walk forward from the original start; a series that begins after the window + // simply yields nothing until it reaches it + while (count($occurrences) < $limit) { + + $candidate = clone $base; + + if ($i > 0) { + $candidate->modify("+$i $step"); + + // Monthly on the 31st, or yearly on Feb 29: PHP rolls the overflow into + // the next month, which is not what a recurrence means. RFC 5545 skips + // those occurrences, so skip them here too rather than silently moving + // the event to the 1st or the 3rd. + if (($step === 'month' || $step === 'year') && (int) $candidate->format('j') !== $base_day) { + $i++; + if ($candidate > $to) { + break; + } + continue; + } + } + + if ($candidate > $to) { + break; + } + + if ($candidate >= $from) { + $occurrence_end = null; + if ($duration > 0) { + $end_dt = clone $candidate; + $end_dt->modify("+$duration second"); + $occurrence_end = $end_dt->format('Y-m-d H:i:s'); + } + $occurrences[] = [ + 'start' => $candidate->format('Y-m-d H:i:s'), + 'end' => $occurrence_end + ]; + } + + $i++; + } + + return $occurrences; +} diff --git a/guest/guest_calendar_feed.php b/guest/guest_calendar_feed.php new file mode 100644 index 00000000..722470a8 --- /dev/null +++ b/guest/guest_calendar_feed.php @@ -0,0 +1,114 @@ + 64) { + feedNotFound(); +} + +$feed_key = escapeSql($_GET['key']); + +$sql = mysqli_query( + $mysqli, + "SELECT * FROM calendars + WHERE calendar_feed_key = '$feed_key' + AND calendar_feed_key IS NOT NULL + AND calendar_archived_at IS NULL + LIMIT 1" +); + +if (mysqli_num_rows($sql) !== 1) { + feedNotFound(); +} + +$calendar = mysqli_fetch_assoc($sql); +$calendar_id = intval($calendar['calendar_id']); + +// Gather events for this calendar only +$window_start = date('Y-m-d H:i:s', strtotime('-' . FEED_MONTHS_PAST . ' months')); +$window_end = date('Y-m-d H:i:s', strtotime('+' . FEED_MONTHS_FUTURE . ' months')); + +$events_sql = mysqli_query( + $mysqli, + "SELECT * FROM calendar_events + WHERE event_calendar_id = $calendar_id + AND event_archived_at IS NULL + AND ( + (event_repeat IS NOT NULL AND event_repeat != '') + OR event_start BETWEEN '$window_start' AND '$window_end' + ) + ORDER BY event_start ASC" +); + +$events = []; +while ($row = mysqli_fetch_assoc($events_sql)) { + $events[] = $row; +} + +$ics = buildCalendarFeedIcs($calendar, $events, $config_timezone, $config_base_url); + +// Let clients that send If-None-Match skip the transfer entirely +$etag = '"' . md5($ics) . '"'; + +if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) { + header("HTTP/1.1 304 Not Modified"); + header("ETag: $etag"); + exit(); +} + +// Record that something is actually subscribed, without turning a hammered URL +// into one write per request +mysqli_query( + $mysqli, + "UPDATE calendars + SET calendar_feed_accessed_at = NOW() + WHERE calendar_id = $calendar_id + AND (calendar_feed_accessed_at IS NULL OR calendar_feed_accessed_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE))" +); + +$filename = preg_replace('/[^A-Za-z0-9_-]/', '_', $calendar['calendar_name']) . '.ics'; + +header("Content-Type: text/calendar; charset=utf-8"); +header("Content-Disposition: inline; filename=\"$filename\""); +// No explicit Content-Length - mod_deflate or nginx gzip would compress the body +// after this point and leave the declared length wrong, which some clients treat +// as a truncated response +header("Cache-Control: private, max-age=300"); +header("ETag: $etag"); + +echo $ics; diff --git a/js/app.js b/js/app.js index 36d15e1a..be9b2a4b 100644 --- a/js/app.js +++ b/js/app.js @@ -410,3 +410,95 @@ $(document).ready(function() { // Data Tables new DataTable('.dataTables'); }); + +/* + * Calendar event modals - the All day switch shows or hides the time row. + * + * The form uses four separate fields (date from / date to / time from / time to), + * so nothing here rewrites a value or changes an input type. An earlier version + * flipped a single datetime-local input to type="date", which a browser answers by + * silently discarding a value it now considers invalid - a fragile arrangement that + * left the fields empty whenever this handler had not run. + * + * required tracks visibility: a hidden required field blocks form submission with an + * unfocusable-element error that the user cannot act on. + * + * Delegated on document because the edit event modal is injected by ajax. The + * namespaced .off() keeps this to a single handler - modal_footer.php re-loads this + * file on every ajax modal open. + */ +$(document).off('change.itflowAllDay').on('change.itflowAllDay', '.event-all-day-toggle', function () { + + const allDay = this.checked; + const timeFields = document.getElementById(this.id.replace(/_all_day$/, '_time_fields')); + + if (!timeFields) { + return; + } + + timeFields.classList.toggle('d-none', allDay); + + timeFields.querySelectorAll('input').forEach(function (field) { + if (allDay) { + field.removeAttribute('required'); + } else { + field.setAttribute('required', 'required'); + } + }); +}); + +/* + * Keep the end date at or after the start date, without shortening a longer span + * the user has already chosen. + */ +$(document).off('change.itflowEventDate').on('change.itflowEventDate', '.event-start-date', function () { + + const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date')); + + if (!endField || !this.value) { + return; + } + + if (!endField.value || endField.value < this.value) { + endField.value = this.value; + } +}); + +/* + * Default the end time to an hour after the start, leaving a longer span alone. + * A start late in the evening rolls the end onto the following day rather than + * wrapping round to an end that precedes the start. + */ +$(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-start-time', function () { + + const prefix = this.id.replace(/_start_time$/, ''); + const endField = document.getElementById(prefix + '_end_time'); + + if (!endField || !this.value) { + return; + } + + if (endField.value && endField.value > this.value) { + return; + } + + const parts = this.value.split(':'); + const end = new Date(2000, 0, 1, Number(parts[0]), Number(parts[1])); + end.setHours(end.getHours() + 1); + + const pad = (n) => String(n).padStart(2, '0'); + endField.value = pad(end.getHours()) + ':' + pad(end.getMinutes()); + + // Crossed midnight - carry the end date forward so the event still ends after + // it starts + if (end.getDate() !== 1) { + const startDate = document.getElementById(prefix + '_start_date'); + const endDate = document.getElementById(prefix + '_end_date'); + + if (startDate && endDate && startDate.value && endDate.value <= startDate.value) { + const next = new Date(startDate.value + 'T00:00:00'); + next.setDate(next.getDate() + 1); + endDate.value = next.getFullYear() + '-' + pad(next.getMonth() + 1) + '-' + pad(next.getDate()); + } + } +}); diff --git a/js/confirm_modal.js b/js/confirm_modal.js index 3055ef04..682a5211 100644 --- a/js/confirm_modal.js +++ b/js/confirm_modal.js @@ -1,5 +1,7 @@ +// Delegated on document rather than bound to the links present at page load, so +// that confirm-link also works on markup injected by an ajax modal $(document).ready(function() { - $("a.confirm-link").click(function(e) { + $(document).off('click.itflowConfirm').on('click.itflowConfirm', 'a.confirm-link', function(e) { e.preventDefault(); // Save the link reference to use after confirmation