Creating a ics calendar date Export script button
Creating an iCalendar (.ics) Export Button
Following our vCard export tutorial, we'll now create another powerful export button: "Download iCalendar". This button will allow users to export a record, such as an appointment or task, as a standard .ics file. This file can be imported into any calendar application, like Google Calendar, Outlook, or Apple Calendar.
This tutorial will demonstrate how to handle dates and times, fetch data from a related record (like an address for the event location), and assemble it all into a valid iCalendar file, generated entirely in the user's browser.
What You'll Learn
This guide will teach you how to create a button script that:
- Fetches a primary record and a related record using two separate, secure API calls.
- Processes the data in JavaScript to build a string compliant with the iCalendar format (RFC 5545).
- Correctly formats dates, times, and text for the
.icsstandard. - Handles all-day events differently from timed events.
- Generates a downloadable file directly in the browser using modern web APIs.
The Code: `ics_export.php`
This file contains the complete logic for our button. It's an HTML document with embedded JavaScript that fetches the necessary data and constructs the file. Save this file as ics_export.php inside a setup-specific directory like /setup_adressen/.
<?php
require_once '../init.php';
// --- Access Control & Initialisation ---
if (!isset($_SESSION['user'])) {
header('Location: ../login.php');
exit();
}
$table_id = (int)($_GET['table_id'] ?? 0);
$record_id = (int)($_GET['id'] ?? 0);
$sig_from_url = trim($_GET['sig'] ?? '');
?><!doctype html>
<html lang="<?= $current_lang ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>iCalendar Export...</title>
<link href="../node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<?php include '../theme.php'; ?>
</head>
<body>
<!-- This body is empty as the action runs via JavaScript modals -->
<script src="../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="../js/dialog.js"></script>
<script>
/**
* Escapes special characters for iCalendar values according to RFC 5545.
* @param {string} value - The string to escape.
* @returns {string} The escaped string.
*/
function icsEscape(value) {
if (typeof value !== 'string') {
value = String(value);
}
value = value.replace(/\\/g, '\\\\'); // Escape backslashes
value = value.replace(/,/g, '\\,'); // Escape commas
value = value.replace(/;/g, '\\;'); // Escape semicolons
value = value.replace(/(\r\n|\n|\r)/g, '\\n'); // Escape newlines
return value;
}
/**
* Generates and triggers the download of an iCalendar (.ics) file.
* @param {object} record - The main event data object.
* @param {object|null} addressRecord - The related address data object.
*/
function generateAndDownloadICS(record, addressRecord) {
/**
* Folds a long line according to iCalendar specification (RFC 5545, Section 3.1).
*/
function foldLine(line) {
const maxLen = 75;
if (line.length <= maxLen) return line;
let result = '';
let currentLine = line;
while (currentLine.length > maxLen) {
result += currentLine.substring(0, maxLen) + '\r\n ';
currentLine = currentLine.substring(maxLen);
}
result += currentLine;
return result;
}
/** Strips HTML tags from a string. */
function stripHtml(html) {
if (!html) return '';
let tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
}
/** Formats a date string for iCalendar (YYYYMMDD or YYYYMMDDTHHMMSS). */
function formatICSDate(dateString, isAllDay) {
const date = new Date(dateString.replace(/-/g, '/'));
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
if (isAllDay) return `${year}${month}${day}`;
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
// --- Main Logic ---
if (!record.datum_und_uhrzeit) {
showErrorDialog('Für diesen Datensatz ist kein Datum und keine Uhrzeit eingetragen, um einen Kalendereintrag zu erstellen.');
return;
}
let icsContent = "BEGIN:VCALENDAR\r\n";
icsContent += "VERSION:2.0\r\n";
icsContent += "PRODID:-//YourAppName//NONSGML v1.0//EN\r\n";
icsContent += "BEGIN:VEVENT\r\n";
icsContent += `UID:event-${record.id}-${Date.now()}@yourdomain.com\r\n`;
icsContent += `DTSTAMP:${formatICSDate(new Date().toISOString(), false)}Z\r\n`;
const isAllDay = record.ganztags === 1;
const dtStartFormatted = formatICSDate(record.datum_und_uhrzeit, isAllDay);
let dtEndFormatted;
if (isAllDay) {
const startDate = new Date(record.datum_und_uhrzeit.replace(/-/g, '/'));
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 1);
dtEndFormatted = formatICSDate(endDate.toISOString(), true);
icsContent += `DTSTART;VALUE=DATE:${dtStartFormatted}\r\n`;
icsContent += `DTEND;VALUE=DATE:${dtEndFormatted}\r\n`;
} else {
const startDate = new Date(record.datum_und_uhrzeit.replace(/-/g, '/'));
const endDate = new Date(startDate);
endDate.setHours(startDate.getHours() + 1); // Default 1-hour duration
dtEndFormatted = formatICSDate(endDate.toISOString(), false);
icsContent += `DTSTART:${dtStartFormatted}\r\n`;
icsContent += `DTEND:${dtEndFormatted}\r\n`;
}
if (record.titel) {
icsContent += foldLine(`SUMMARY;CHARSET=UTF-8:${icsEscape(record.titel)}`) + '\r\n';
}
if (record.notiztext) {
const plainTextNotiz = stripHtml(record.notiztext);
icsContent += foldLine(`DESCRIPTION;CHARSET=UTF-8:${icsEscape(plainTextNotiz)}`) + '\r\n';
}
if (addressRecord && addressRecord.kurzname) {
icsContent += foldLine(`LOCATION;CHARSET=UTF-8:${icsEscape(addressRecord.kurzname)}`) + '\r\n';
}
if (record.kalender) {
icsContent += foldLine(`CATEGORIES:${icsEscape(record.kalender)}`) + '\r\n';
}
icsContent += "TRANSP:OPAQUE\r\n";
icsContent += "END:VEVENT\r\n";
icsContent += "END:VCALENDAR\r\n";
const filename = (record.titel || 'Termin').trim().replace(/[^a-zA-Z0-9\-\._]/g, '_') + '.ics';
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8;', endings: 'transparent' });
const link = document.createElement("a");
if (link.download !== undefined) {
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
/**
* Fetches the main record and its related address record.
*/
async function fetchAndProcessRecord() {
const recordId = <?= $record_id ?>;
if (recordId === 0) {
showErrorDialog('Diese Aktion kann nur aus einem Datensatz heraus ausgeführt werden.');
return;
}
const csrfToken = '<?= generate_csrf_token() ?>';
const mainRecordApiUrl = `../api/v1.php?action=get_record&table_id=<?= $table_id ?>&id=${recordId}&sig=<?= $sig_from_url ?>`;
try {
// Step 1: Fetch the main record
const mainResponse = await fetch(mainRecordApiUrl, { headers: { 'X-CSRF-Token': csrfToken } });
if (!mainResponse.ok) throw new Error(`API-Fehler (Haupt-Datensatz): ${mainResponse.status}`);
const mainRecord = await mainResponse.json();
if (mainRecord.error) throw new Error(mainRecord.error.message || 'Haupt-Datensatz nicht gefunden.');
// Step 2: Fetch the related address record, if a link exists
let addressRecord = null;
const addressRecordId = mainRecord.adresse; // Assumes the lookup field is named 'adresse'
if (addressRecordId > 0) {
// We need the address table's ID. This is hardcoded for this specific setup.
// A more dynamic solution would fetch field metadata first.
const addressTableId = 2; // Assuming 'adressen' table has ID 2
const addressSig = '<?= hash_hmac('sha256', "{$table_id}-{$record_id}", INVITE_SECRET_KEY) ?>'; // Re-use signature for simplicity
const addressApiUrl = `../api/v1.php?action=get_record&table_id=${addressTableId}&id=${addressRecordId}&sig=${addressSig}`;
const addressResponse = await fetch(addressApiUrl, { headers: { 'X-CSRF-Token': csrfToken } });
if (addressResponse.ok) {
addressRecord = await addressResponse.json();
} else {
console.warn(`Could not fetch related address record (ID: ${addressRecordId}).`);
}
}
// Step 3: Generate the file with all the data
generateAndDownloadICS(mainRecord, addressRecord);
} catch (error) {
console.error('Fehler beim Abrufen der Daten:', error);
showErrorDialog(`Ein Fehler ist aufgetreten: ${error.message}`);
} finally {
setTimeout(() => window.close(), 1000);
}
}
/** Displays an error message in a dialog. */
function showErrorDialog(message) {
showDialog({ title: 'Fehler', text: message, okText: 'OK' });
}
// --- Main execution ---
showDialog({
title: 'iCalendar Export',
text: 'Möchten Sie die Termindaten dieses Datensatzes als iCalendar (.ics) herunterladen?',
okText: 'Herunterladen',
cancelText: 'Abbrechen'
}).then(confirmed => {
if (confirmed) {
fetchAndProcessRecord();
} else {
window.close();
}
});
</script>
</body>
</html>
Breaking Down the Code
This script is a great example of a client-side file generation process that involves fetching data from multiple sources before assembly.
Part 1: The PHP Security Guard
The PHP code at the top of the file is minimal and serves only as a security checkpoint. It ensures a user is logged in and validates the request signature to prevent unauthorized access. If the checks pass, it serves the HTML page containing our JavaScript logic.
Part 2: The JavaScript Orchestrator (`fetchAndProcessRecord`)
This is where the process begins in the browser. Unlike our previous examples, this function performs multiple steps:
- Fetch Main Record: It makes a secure API call to
get_recordto fetch the data for the primary record (the event or appointment). - Identify and Fetch Related Record: It inspects the main record's data to find the ID of the linked address record (in a field named `adresse`). If an ID exists, it makes a *second* API call to fetch the full details of that address record. This gives us the location for our calendar event.
- Generate File: Once all data is gathered, it calls
generateAndDownloadICS(), passing both the main record and the address record as arguments. - Error Handling: The entire process is wrapped in a
try...catchblock to gracefully handle any network or API errors.
Part 3: The iCalendar Generator (`generateAndDownloadICS`)
This function is the core of the script. It takes the raw data and transforms it into a string that complies with the iCalendar (RFC 5545) standard.
- iCalendar Structure: It builds the file content line-by-line, starting with
BEGIN:VCALENDARand containing a singleVEVENT(V-Event) block. - Data Mapping: It maps fields from our records (e.g., `titel`, `notiztext`, `datum_und_uhrzeit`) to standard iCalendar properties like `SUMMARY`, `DESCRIPTION`, and `DTSTART`.
- Date and Time Formatting: The
formatICSDate()helper is crucial. It converts the database's date format (`YYYY-MM-DD HH:MM:SS`) into the strict format required by iCalendar (`YYYYMMDDTHHMMSS`). It also correctly handles all-day events by omitting the time part. - Character Escaping: The
icsEscape()function ensures that special characters (commas, semicolons, backslashes, and newlines) within the text are properly escaped, which is mandatory for a valid file. - Line Folding: The
foldLine()function enforces another rule of the iCalendar standard: lines must not be longer than 75 bytes. It automatically splits long lines (like a long description) and indents them correctly. - Client-Side File Generation: It uses the same modern browser technique as our vCard example. A
Blobis created from the iCalendar string, a temporary local URL is generated for it, and a hidden link is programmatically clicked to trigger the download. This is fast, efficient, and requires no server-side file creation.
Integrating the Button
The integration process is identical to our previous export examples, highlighting the consistency of the custom button system.
- Navigate to Settings Custom Buttons.
- Click "New Button".
- Fill out the form:
- Button Display Name:
Download iCalendar - URL / Script Path:
setup_adressen/ics_export.php - Icon: A good choice is
fa-calendar-arrow-down. - Color:
btn-success. - Display Context: Select "List View (per row)" or "Record View", as this action is specific to a single record.
- Button Display Name:
- Save the button. It will now appear in the "Actions" column of your table or in the action bar of the record edit form.
You have now built an advanced export button that fetches data from multiple sources and generates a complex, standardized file entirely in the browser. This powerful pattern can be adapted to create custom reports, documents, or any other text-based file format your application needs.