mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
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
This commit is contained in:
45
CHANGELOG.md
45
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.
|
||||
|
||||
27
admin/database_updates/2.5.8.php
Normal file
27
admin/database_updates/2.5.8.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.5.8 (from 2.5.7)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// Calendars can now be published as a read-only ICS feed for Google Calendar,
|
||||
// Nextcloud and any other subscription client. The key is stored in cleartext,
|
||||
// matching invoice_url_key and shared_items.item_key - hashing it would buy
|
||||
// nothing here (anyone holding the database already holds the events the key
|
||||
// grants access to) and would make the URL impossible to re-copy for a second
|
||||
// device without breaking every existing subscriber.
|
||||
//
|
||||
// The UNIQUE key is on a nullable column, so unshared calendars all keep NULL.
|
||||
// The column is explicitly utf8mb4_bin: the table default is utf8mb4_general_ci,
|
||||
// which compares case-insensitively, and the key alphabet is mixed-case
|
||||
// base64url - a _ci column would throw away entropy on lookup and make the
|
||||
// UNIQUE index blind to case.
|
||||
mysqli_query($mysqli, "ALTER TABLE `calendars`
|
||||
ADD COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL AFTER `calendar_color`,
|
||||
ADD COLUMN `calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0 AFTER `calendar_feed_key`,
|
||||
ADD COLUMN `calendar_feed_created_at` datetime DEFAULT NULL AFTER `calendar_feed_busy_only`,
|
||||
ADD COLUMN `calendar_feed_accessed_at` datetime DEFAULT NULL AFTER `calendar_feed_created_at`,
|
||||
ADD UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`)");
|
||||
28
admin/database_updates/2.5.9.php
Normal file
28
admin/database_updates/2.5.9.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.5.9 (from 2.5.8)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// Events can now be marked all-day explicitly. Until now calendar_events had
|
||||
// no all-day column at all - FullCalendar inferred it from a start value with
|
||||
// no time component, which meant a genuine midnight appointment was
|
||||
// indistinguishable from an all-day event.
|
||||
mysqli_query($mysqli, "ALTER TABLE `calendar_events`
|
||||
ADD COLUMN `event_all_day` tinyint(1) NOT NULL DEFAULT 0 AFTER `event_end`");
|
||||
|
||||
// Backfill using the same rule the calendar already rendered by, so existing
|
||||
// events keep displaying exactly as they do today: a midnight start, and
|
||||
// either no end or a midnight end.
|
||||
mysqli_query($mysqli, "UPDATE `calendar_events`
|
||||
SET `event_all_day` = 1
|
||||
WHERE TIME(`event_start`) = '00:00:00'
|
||||
AND (`event_end` IS NULL OR TIME(`event_end`) = '00:00:00')");
|
||||
|
||||
// Corrects the collation on installs that already applied 2.5.8 before the
|
||||
// column was pinned to utf8mb4_bin. Harmless to re-run.
|
||||
mysqli_query($mysqli, "ALTER TABLE `calendars`
|
||||
MODIFY COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL");
|
||||
@@ -43,9 +43,13 @@ if (isset($_GET['calendar_id'])) {
|
||||
$calendar_id = intval($row['calendar_id']);
|
||||
$calendar_name = escapeHtml($row['calendar_name']);
|
||||
$calendar_color = escapeHtml($row['calendar_color']);
|
||||
$calendar_feed_key = escapeHtml($row['calendar_feed_key'] ?? null);
|
||||
?>
|
||||
<div class="form-group d-flex align-items-center">
|
||||
<i class="fas fa-fw fa-circle mr-2" style="color:<?= $calendar_color ?>;"></i><?= $calendar_name ?>
|
||||
<?php if (!empty($calendar_feed_key)) { ?>
|
||||
<i class="fas fa-fw fa-share-alt text-info ml-2" title="Published as a subscription link"></i>
|
||||
<?php } ?>
|
||||
|
||||
<div class="dropdown dropright ml-auto">
|
||||
<button class="btn btn-tool" type="button" data-toggle="dropdown">
|
||||
@@ -56,6 +60,12 @@ if (isset($_GET['calendar_id'])) {
|
||||
data-modal-url="modals/calendar/calendar_edit.php?id=<?= $calendar_id ?>">
|
||||
<i class="fas fa-fw fa-pencil-alt mr-2"></i>Rename
|
||||
</a>
|
||||
<?php if ($session_is_admin) { ?>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/calendar/calendar_share.php?id=<?= $calendar_id ?>">
|
||||
<i class="fas fa-fw fa-share-alt mr-2"></i><?= empty($calendar_feed_key) ? 'Share' : 'Manage sharing' ?>
|
||||
</a>
|
||||
<?php } ?>
|
||||
<?php if ($session_user_role == 3) { ?>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_calendar=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
@@ -153,6 +163,19 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
||||
<script src='/libs/fullcalendar/themes/classic/global.js'></script>
|
||||
|
||||
<script>
|
||||
// Local-time formatters for the date and datetime-local inputs.
|
||||
// Date.toISOString() would convert to UTC and silently shift the event by the
|
||||
// browser's offset.
|
||||
function formatLocalDate(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
|
||||
}
|
||||
|
||||
function formatLocalTime(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return pad(date.getHours()) + ":" + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
|
||||
@@ -163,6 +186,13 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
||||
text: 'New Event',
|
||||
iconClass: 'fas fa-plus',
|
||||
click: function() {
|
||||
// Reset to the all-day default; without this the modal keeps
|
||||
// whatever state the last calendar selection left it in
|
||||
const allDayToggle = document.getElementById("event_add_all_day");
|
||||
if (allDayToggle) {
|
||||
allDayToggle.checked = true;
|
||||
$(allDayToggle).trigger("change");
|
||||
}
|
||||
$("#addCalendarEventModal").modal();
|
||||
}
|
||||
}
|
||||
@@ -185,6 +215,65 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
||||
eventDidMount: function(info) {
|
||||
// Always show full title when hovering
|
||||
info.el.setAttribute('title', info.event.title);
|
||||
|
||||
// Mark occurrences of a repeating event, so a series is recognisable
|
||||
// without opening it. Every occurrence carries the parent event id, so
|
||||
// this is the only cue that a click will edit the whole series.
|
||||
const repeat = info.event.extendedProps.repeat;
|
||||
if (repeat) {
|
||||
info.el.setAttribute('title', info.event.title + ' (repeats every ' + repeat.toLowerCase() + ')');
|
||||
|
||||
const titleEl = info.el.querySelector('.fc-event-title') || info.el.querySelector('.fc-list-event-title');
|
||||
if (titleEl) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-redo fa-xs mr-1';
|
||||
titleEl.prepend(icon);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Clicking - or dragging across - empty calendar space opens the New Event
|
||||
// modal prefilled with whatever was selected. Month view yields an all-day
|
||||
// range; the time grid views yield a slot range.
|
||||
select: function(selectionInfo) {
|
||||
|
||||
const allDayToggle = document.getElementById("event_add_all_day");
|
||||
const startDate = document.getElementById("event_add_start_date");
|
||||
const endDate = document.getElementById("event_add_end_date");
|
||||
const startTime = document.getElementById("event_add_start_time");
|
||||
const endTime = document.getElementById("event_add_end_time");
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FullCalendar reports an exclusive end for an all-day range, but the form
|
||||
// asks for the last day the event covers - step back a day so a single-day
|
||||
// click does not come back as a two-day event. A time-grid selection is not
|
||||
// exclusive in the same way, so its end date is used as-is (a slot running
|
||||
// to midnight legitimately ends on the next day).
|
||||
const lastDay = new Date(selectionInfo.end.getTime());
|
||||
if (selectionInfo.allDay) {
|
||||
lastDay.setDate(lastDay.getDate() - 1);
|
||||
}
|
||||
|
||||
startDate.value = formatLocalDate(selectionInfo.start);
|
||||
endDate.value = formatLocalDate(lastDay);
|
||||
|
||||
// A time-grid selection carries a real time; a month-view click does not,
|
||||
// so its time fields are left for the user to fill in if they uncheck
|
||||
if (!selectionInfo.allDay && startTime && endTime) {
|
||||
startTime.value = formatLocalTime(selectionInfo.start);
|
||||
endTime.value = formatLocalTime(selectionInfo.end);
|
||||
}
|
||||
|
||||
// Last, so the handler in app.js shows or hides the time row to match
|
||||
if (allDayToggle) {
|
||||
allDayToggle.checked = selectionInfo.allDay;
|
||||
$(allDayToggle).trigger("change");
|
||||
}
|
||||
|
||||
calendar.unselect();
|
||||
$("#addCalendarEventModal").modal();
|
||||
},
|
||||
eventClick: function(editEvent) {
|
||||
var eventId = editEvent.event.id;
|
||||
@@ -215,16 +304,40 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
||||
events: [
|
||||
<?php
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM calendar_events LEFT JOIN calendars ON event_calendar_id = calendar_id $client_event_query");
|
||||
|
||||
// Repeating events are stored as a single row, so the occurrences have to
|
||||
// be materialised here - the bundled FullCalendar build has no rrule
|
||||
// plugin. Every occurrence keeps the parent event_id, so clicking any of
|
||||
// them opens the series in the edit modal.
|
||||
$recur_window_start = date('Y-m-d H:i:s', strtotime('-6 months'));
|
||||
$recur_window_end = date('Y-m-d H:i:s', strtotime('+18 months'));
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$event_id = intval($row['event_id']);
|
||||
$event_title = json_encode($row['event_title']);
|
||||
$event_start = json_encode($row['event_start']);
|
||||
$event_end = json_encode($row['event_end']);
|
||||
$calendar_id = intval($row['calendar_id']);
|
||||
$calendar_name = json_encode($row['calendar_name']);
|
||||
$calendar_color = json_encode($row['calendar_color']);
|
||||
$event_is_all_day = !empty($row['event_all_day'] ?? 0);
|
||||
$event_all_day = $event_is_all_day ? 'true' : 'false';
|
||||
$event_repeat = json_encode($row['event_repeat'] ?? '');
|
||||
|
||||
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, color: $calendar_color },";
|
||||
foreach (expandRecurringEvent($row, $recur_window_start, $recur_window_end) as $occurrence) {
|
||||
|
||||
$occurrence_end = $occurrence['end'];
|
||||
|
||||
// event_end holds the last day an all-day event covers, but
|
||||
// FullCalendar's all-day end is exclusive - without this the final
|
||||
// day of a multi-day event is not drawn
|
||||
if ($event_is_all_day && !empty($occurrence_end)) {
|
||||
$occurrence_end = date('Y-m-d H:i:s', strtotime($occurrence_end . ' +1 day'));
|
||||
}
|
||||
|
||||
$event_start = json_encode($occurrence['start']);
|
||||
$event_end = json_encode($occurrence_end);
|
||||
|
||||
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, allDay: $event_all_day, color: $calendar_color, extendedProps: { repeat: $event_repeat } },";
|
||||
}
|
||||
}
|
||||
|
||||
// Invoices Created
|
||||
@@ -384,31 +497,3 @@ while ($row = mysqli_fetch_assoc($sql)) {
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Automatically set new event end date to 1 hr after start date -->
|
||||
<script>
|
||||
// Function - called when user leaves field (onblur)
|
||||
function updateIncrementEndTime() {
|
||||
|
||||
// Get the start date
|
||||
let start = document.getElementById("event_add_start").value;
|
||||
|
||||
// Create a date object
|
||||
let new_end = new Date(start);
|
||||
|
||||
// Get the time zone offset in minutes, convert it to milliseconds
|
||||
let offsetInMilliseconds = new_end.getTimezoneOffset() * 60 * 1000;
|
||||
|
||||
// Adjust the date by the time zone offset before adding an hour
|
||||
new_end = new Date(new_end.getTime() - offsetInMilliseconds);
|
||||
|
||||
// Set the end date to 1 hr in the future
|
||||
new_end.setHours(new_end.getHours() + 1);
|
||||
|
||||
// Get the date back as a string, with the milliseconds trimmed off
|
||||
new_end = new_end.toISOString().replace(/.\d+Z$/g, "");
|
||||
|
||||
// Update the end date field
|
||||
document.getElementById("event_add_end").value = new_end;
|
||||
}
|
||||
</script>
|
||||
@@ -62,22 +62,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label>Start / End <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
|
||||
</div>
|
||||
<input type="datetime-local" class="form-control" id="event_add_start" name="start" required onblur="updateIncrementEndTime()">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input event-all-day-toggle" id="event_add_all_day" name="all_day" value="1" checked>
|
||||
<label class="custom-control-label" for="event_add_all_day">All day</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label>Date from <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
|
||||
</div>
|
||||
<input type="date" class="form-control event-start-date" id="event_add_start_date" name="start_date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label>Date to <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
|
||||
</div>
|
||||
<input type="date" class="form-control" id="event_add_end_date" name="end_date" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden while All day is on. The toggle handler in app.js also
|
||||
adds and removes required, because a hidden required field
|
||||
blocks submission with an unfocusable-element error. -->
|
||||
<div class="form-row d-none" id="event_add_time_fields">
|
||||
<div class="form-group col-md-6">
|
||||
<label>Time from <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
|
||||
</div>
|
||||
<input type="time" class="form-control event-start-time" id="event_add_start_time" name="start_time">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label>Time to <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
|
||||
</div>
|
||||
<input type="time" class="form-control" id="event_add_end_time" name="end_time">
|
||||
</div>
|
||||
<input type="datetime-local" class="form-control" id="event_add_end" name="end" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +121,7 @@
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-recycle"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="repeat" disabled>
|
||||
<select class="form-control select2" name="repeat">
|
||||
<option value="">Never</option>
|
||||
<option>Day</option>
|
||||
<option>Week</option>
|
||||
|
||||
@@ -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();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($event_repeat)) { ?>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-fw fa-redo mr-2"></i>
|
||||
This event repeats <strong>every <?= strtolower($event_repeat) ?></strong>.
|
||||
Saving or deleting affects <strong>every occurrence</strong> — a single
|
||||
occurrence cannot be moved or cancelled on its own.
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Start / End <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
|
||||
</div>
|
||||
<input type="datetime-local" class="form-control" name="start" value="<?= date('Y-m-d\TH:i:s', strtotime($event_start)) ?>" required>
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input event-all-day-toggle" id="event_edit_all_day" name="all_day" value="1" <?php if ($event_all_day) { echo "checked"; } ?>>
|
||||
<label class="custom-control-label" for="event_edit_all_day">All day</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label>Date from <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
|
||||
</div>
|
||||
<input type="date" class="form-control event-start-date" id="event_edit_start_date" name="start_date" value="<?= $event_start_date ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label>Date to <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
|
||||
</div>
|
||||
<input type="date" class="form-control" id="event_edit_end_date" name="end_date" value="<?= $event_end_date ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row<?= $event_all_day ? ' d-none' : '' ?>" id="event_edit_time_fields">
|
||||
<div class="form-group col-md-6">
|
||||
<label>Time from <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
|
||||
</div>
|
||||
<input type="time" class="form-control event-start-time" id="event_edit_start_time" name="start_time" value="<?= $event_start_time ?>"<?= $event_all_day ? '' : ' required' ?>>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label>Time to <strong class="text-danger">*</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
|
||||
</div>
|
||||
<input type="time" class="form-control" id="event_edit_end_time" name="end_time" value="<?= $event_end_time ?>"<?= $event_all_day ? '' : ' required' ?>>
|
||||
</div>
|
||||
<input type="datetime-local" class="form-control" name="end" value="<?= date('Y-m-d\TH:i:s', strtotime($event_end)) ?>"required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +165,7 @@ ob_start();
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-recycle"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="repeat" disabled>
|
||||
<select class="form-control select2" name="repeat">
|
||||
<option <?php if (empty($event_repeat)) { echo "selected"; } ?> value="">Never</option>
|
||||
<option <?php if ($event_repeat == "Day") { echo "selected"; } ?>>Day</option>
|
||||
<option <?php if ($event_repeat == "Week") { echo "selected"; } ?>>Week</option>
|
||||
@@ -190,7 +241,7 @@ ob_start();
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-default text-danger mr-auto" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times mr-2"></i>Delete</a>
|
||||
<a class="btn btn-default text-danger mr-auto confirm-link" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times mr-2"></i><?= empty($event_repeat) ? 'Delete' : 'Delete series' ?></a>
|
||||
<button type="submit" name="edit_event" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Save</button>
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fa fa-times mr-2"></i>Cancel</button>
|
||||
</div>
|
||||
|
||||
189
agent/modals/calendar/calendar_share.php
Normal file
189
agent/modals/calendar/calendar_share.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_header.php';
|
||||
|
||||
$calendar_id = intval($_GET['id']);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM calendars WHERE calendar_id = $calendar_id LIMIT 1");
|
||||
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
$calendar_name = escapeHtml($row['calendar_name']);
|
||||
$calendar_color = escapeHtml($row['calendar_color']);
|
||||
$calendar_feed_key = escapeHtml($row['calendar_feed_key']);
|
||||
$calendar_feed_busy_only = intval($row['calendar_feed_busy_only']);
|
||||
$calendar_feed_created_at = escapeHtml($row['calendar_feed_created_at']);
|
||||
$calendar_feed_accessed_at = escapeHtml($row['calendar_feed_accessed_at']);
|
||||
|
||||
// $config_base_url lives in config.php (written at setup from HTTP_HOST, or from
|
||||
// the --base_url argument to setup_cli.php) and is expected to be a bare host.
|
||||
// Tolerate a scheme having been saved into it rather than emitting https://https://
|
||||
$base_url = rtrim(preg_replace('#^[a-z]+://#i', '', (string) $config_base_url), '/');
|
||||
$feed_path = "/guest/guest_calendar_feed.php?key=$calendar_feed_key";
|
||||
$feed_url_https = "https://$base_url$feed_path";
|
||||
$feed_url_webcal = "webcal://$base_url$feed_path";
|
||||
|
||||
// Generate the HTML form content using output buffering.
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title"><i class="fas fa-fw fa-share-alt mr-2"></i>Share <?= $calendar_name ?></h5>
|
||||
<button type="button" class="close text-white" data-dismiss="modal">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php if (empty($base_url)) { ?>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
|
||||
<strong>$config_base_url is not set in config.php.</strong>
|
||||
Without it there is no hostname to build a subscription link from. Set it
|
||||
to the hostname this install is reached on and reopen this dialog.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal">
|
||||
<i class="fas fa-fw fa-times mr-2"></i>Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php } elseif (empty($calendar_feed_key)) { ?>
|
||||
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
<input type="hidden" name="calendar_id" value="<?= $calendar_id ?>">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<p>
|
||||
Publishing <strong><?= $calendar_name ?></strong> creates a secret link that
|
||||
Google Calendar, Nextcloud, Apple Calendar or Thunderbird can subscribe to.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
|
||||
Anyone with the link can read this calendar without logging in. Share it
|
||||
like a password, and revoke it here if it gets out.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="feedBusyOnly" name="busy_only" value="1">
|
||||
<label class="custom-control-label" for="feedBusyOnly">
|
||||
Busy only — publish time blocks without titles, descriptions or locations
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="share_calendar" class="btn btn-primary">
|
||||
<i class="fas fa-fw fa-link mr-2"></i>Create link
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal">
|
||||
<i class="fas fa-fw fa-times mr-2"></i>Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php } else { ?>
|
||||
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
<input type="hidden" name="calendar_id" value="<?= $calendar_id ?>">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subscription link</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" value="<?= $feed_url_https ?>" readonly onclick="this.select();">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary clipboardjs" type="button" data-clipboard-text="<?= $feed_url_https ?>" title="Copy link">
|
||||
<i class="far fa-fw fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
Paste this into <strong>Google Calendar</strong> → Other calendars → From URL,
|
||||
or <strong>Nextcloud Calendar</strong> → New calendar → New subscription.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Or open directly in a desktop calendar app</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" value="<?= $feed_url_webcal ?>" readonly onclick="this.select();">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary clipboardjs" type="button" data-clipboard-text="<?= $feed_url_webcal ?>" title="Copy link">
|
||||
<i class="far fa-fw fa-copy"></i>
|
||||
</button>
|
||||
<a class="btn btn-secondary" href="<?= $feed_url_webcal ?>" title="Open">
|
||||
<i class="fas fa-fw fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="feedBusyOnly" name="busy_only" value="1" <?php if ($calendar_feed_busy_only == 1) { echo "checked"; } ?>>
|
||||
<label class="custom-control-label" for="feedBusyOnly">
|
||||
Busy only — publish time blocks without titles, descriptions or locations
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="row mb-0 text-muted">
|
||||
<dt class="col-5">Link created</dt>
|
||||
<dd class="col-7"><?= $calendar_feed_created_at ?: 'Unknown' ?></dd>
|
||||
<dt class="col-5">Last fetched</dt>
|
||||
<dd class="col-7"><?= $calendar_feed_accessed_at ?: 'Never' ?></dd>
|
||||
</dl>
|
||||
|
||||
<div class="alert alert-secondary mt-3 mb-0">
|
||||
<i class="fas fa-fw fa-info-circle mr-2"></i>
|
||||
Subscriptions are read-only, and clients decide how often to refresh.
|
||||
Google refreshes on its own schedule (often 12–24 hours) and cannot be
|
||||
forced. Nextcloud defaults to once a week unless
|
||||
<code>calendarSubscriptionRefreshRate</code> is lowered, and refuses
|
||||
subscriptions pointing at a private IP address.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<div>
|
||||
<button type="submit" name="share_calendar" class="btn btn-primary">
|
||||
<i class="fas fa-fw fa-check mr-2"></i>Save
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal">
|
||||
<i class="fas fa-fw fa-times mr-2"></i>Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown dropup">
|
||||
<button class="btn btn-light" type="button" data-toggle="dropdown">
|
||||
<i class="fas fa-fw fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item confirm-link" href="post.php?regenerate_calendar_feed=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-sync mr-2"></i>Regenerate link
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?unshare_calendar=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-unlink mr-2"></i>Stop sharing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php } ?>
|
||||
|
||||
<?php
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
@@ -68,6 +68,103 @@ if (isset($_GET['delete_calendar'])) {
|
||||
|
||||
}
|
||||
|
||||
if (isset($_POST['share_calendar'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
// Publishing a calendar mints an unauthenticated public URL, so keep it to
|
||||
// admins. This is a deliberately narrow check using $session_is_admin rather
|
||||
// than the module permission system - calendars are not owned by a module.
|
||||
enforceAdminPermission();
|
||||
|
||||
$calendar_id = intval($_POST['calendar_id']);
|
||||
$busy_only = isset($_POST['busy_only']) ? 1 : 0;
|
||||
|
||||
$calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
|
||||
$existing_key = getFieldById('calendars', $calendar_id, 'calendar_feed_key');
|
||||
|
||||
if (empty($existing_key)) {
|
||||
|
||||
// 32 URL-safe base64 chars (~192 bits). Stored in cleartext, like
|
||||
// invoice_url_key, so the link stays re-copyable for a second device.
|
||||
$feed_key = escapeSql(randomString(32));
|
||||
|
||||
mysqli_query($mysqli, "UPDATE calendars SET
|
||||
calendar_feed_key = '$feed_key',
|
||||
calendar_feed_busy_only = $busy_only,
|
||||
calendar_feed_created_at = NOW()
|
||||
WHERE calendar_id = $calendar_id");
|
||||
|
||||
logAudit("Calendar", "Share", "$session_name published calendar $calendar_name as a read-only feed", 0, $calendar_id);
|
||||
|
||||
flashAlert("Calendar <strong>$calendar_name</strong> 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 <strong>$calendar_name</strong> 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 <strong>$calendar_name</strong> 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 <strong>$calendar_name</strong> 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
8
db.sql
8
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 */;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
371
functions/calendar.php
Normal file
371
functions/calendar.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
// iCalendar (RFC 5545) generation for the read-only guest calendar feeds
|
||||
// Consumed by guest/guest_calendar_feed.php
|
||||
|
||||
|
||||
/*
|
||||
* Escapes a value for an iCalendar TEXT property (RFC 5545 3.3.11)
|
||||
* Backslashes are escaped first, otherwise the escapes added below get escaped again
|
||||
*/
|
||||
function icsEscapeText($text) {
|
||||
$text = str_replace("\\", "\\\\", (string) $text);
|
||||
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
|
||||
return str_replace([";", ","], ["\\;", "\\,"], $text);
|
||||
}
|
||||
|
||||
/*
|
||||
* Folds a content line to 75 octets per RFC 5545 3.1
|
||||
* Continuation lines begin with a single space which counts toward the 75, so
|
||||
* $buffer already carries it. Folding happens on character boundaries so a
|
||||
* multi-byte UTF-8 sequence is never cut in half - splitting mid-sequence is
|
||||
* what makes hand-rolled feeds fail to parse on non-ASCII event titles.
|
||||
*/
|
||||
function icsFoldLine($line) {
|
||||
|
||||
if (strlen($line) <= 75) {
|
||||
return $line;
|
||||
}
|
||||
|
||||
$chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
// Invalid UTF-8 - fold on byte boundaries rather than emit an over-long line
|
||||
if ($chars === false) {
|
||||
return rtrim(chunk_split($line, 74, "\r\n "), "\r\n ");
|
||||
}
|
||||
|
||||
$folded = '';
|
||||
$buffer = '';
|
||||
|
||||
foreach ($chars as $char) {
|
||||
if (strlen($buffer) + strlen($char) > 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;
|
||||
}
|
||||
114
guest/guest_calendar_feed.php
Normal file
114
guest/guest_calendar_feed.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* guest_calendar_feed.php
|
||||
* Read-only iCalendar (ICS) feed for a single ITFlow calendar
|
||||
*
|
||||
* Unauthenticated by design - Google Calendar and Nextcloud fetch subscription
|
||||
* URLs with no credentials at all, so possession of calendar_feed_key is the
|
||||
* only authorisation. The key is 32 URL-safe base64 characters (~192 bits).
|
||||
*
|
||||
* Deliberately does NOT include guest/includes/inc_all_guest.php (that emits the
|
||||
* guest HTML layout) and deliberately does NOT start a session - an unattended
|
||||
* fetcher hitting this every 15 minutes should not be creating session files.
|
||||
*/
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../includes/load_global_settings.php";
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
|
||||
// How much of the calendar to publish. Recurring events are always included
|
||||
// regardless of window, since their first occurrence can predate it.
|
||||
define("FEED_MONTHS_PAST", 12);
|
||||
define("FEED_MONTHS_FUTURE", 24);
|
||||
|
||||
// Never let a leaked feed URL end up in a search index
|
||||
header("X-Robots-Tag: noindex, nofollow");
|
||||
|
||||
/*
|
||||
* Bad or missing key - identical response either way, so the endpoint cannot be
|
||||
* used to tell a wrong key from a revoked one
|
||||
*/
|
||||
function feedNotFound() {
|
||||
header("HTTP/1.1 404 Not Found");
|
||||
header("Content-Type: text/plain; charset=utf-8");
|
||||
echo "Not found.";
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!isset($_GET['key']) || strlen($_GET['key']) < 16 || strlen($_GET['key']) > 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;
|
||||
92
js/app.js
92
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user