Avec le code créé lors des trois précédents articles, nous en sommes arrivés à un agenda pleinement fonctionnel. À présent, nous allons lui ajouter une fonctionnalité bien utile. Lorsque vous utilisez une application et que vous avez de nombreux enregistrements et que vous désirez changer pour une autre, il vous faut un outil de migration. Pour les calendriers, cet outil est le format iCalendar. C'est un format devenu universel utilisé par exemple par Google Calendar, Microsoft Outlook, Thunderbird, et c'est celui que nous allons utiliser pour exporter les données de notre agenda.
Vous pouvez télécharger le code final de cet article ici.
Le format iCalendar
Le format iCalendar (.ics) est un standard ouvert (RFC 5545) permettant l'échange d'informations relatives aux calendriers et à la planification.
Ses principales caractéristiques sont les suivantes :
- extension de fichier : .ics
- format : texte brut avec une structure spécifique
- encodage : UTF-8 recommandé
- type MIME : text/calendar
Structure de base
Voici un exemple basique de fichier iCalendar :
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Votre Société//NONSGML v1.0//FR
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:12345-67890@votre-domaine.com
DTSTAMP:20240101T120000Z
DTSTART:20240115T090000Z
DTEND:20240115T110000Z
SUMMARY:Réunion importante
DESCRIPTION:Discussion projet Q4 avec l'équipe
LOCATION:Salle de conférence A
ORGANIZER;CN="Jean Dupont":MAILTO:jean@entreprise.com
ATTENDEE;CN="Marie Martin";ROLE=REQ-PARTICIPANT:MAILTO:marie@entreprise.com
ATTENDEE;CN="Paul Durand";ROLE=OPT-PARTICIPANT:MAILTO:paul@entreprise.com
END:VEVENT
END:VCALENDAR
Composants principaux
Les événements (VEVENT)
Le composant essentiel est celui qui concerne les événements, en voici un exemple simple :
BEGIN:VEVENT
UID:unique-id@domain.com # Identifiant unique
DTSTAMP:20240101T120000Z # Date de création
DTSTART:20240115T090000Z # Début
DTEND:20240115T110000Z # Fin
SUMMARY:Réunion d'équipe # Titre
DESCRIPTION:Ordre du jour... # Description
LOCATION:Salle 101 # Lieu
END:VEVENT
Les tâches (VTODO)
On peut aussi prévoir des tâches :
BEGIN:VTODO
UID:task-001@domain.com
DTSTAMP:20240101T120000Z
DUE:20240120T170000Z
SUMMARY:Acheter fournitures
PRIORITY:1
STATUS:NEEDS-ACTION
END:VTODO
Les notes journalières (VJOURNAL)
On peut ajouter des notes journalières :
BEGIN:VJOURNAL
UID:journal-001@domain.com
DTSTAMP:20240101T120000Z
DTSTART:20240101
SUMMARY:Notes de la journée
DESCRIPTION:Réflexions importantes...
END:VJOURNAL
Les propriétés essentielles
Voici les propriétés essentielles :
| Propriété | Description | Exemple |
| UID | Identifiant unique global | UID:20240115T090000Z-1234@domaine.com |
| DTSTART/DTEND | Dates début/fin | DTSTART:20240115T090000Z |
| SUMMARY | Titre/sujet | SUMMARY:Réunion budget |
| DESCRIPTION | Description détaillée | DESCRIPTION:Ordre du jour... |
| LOCATION | Lieu | LOCATION:Paris, France |
| ORGANIZER | Organisateur | ORGANIZER;CN="Pierre":mailto:pierre@domaine.com |
| ATTENDEE | Participants | ATTENDEE;ROLE=REQ-PARTICIPANT:mailto:marie@domaine.com |
| RRULE | Répétition | RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE |
On voit que les possibilités sont très riches. Pour notre agenda, on n'utilisera évidemment pas tout ça ! Nous n'avons pas prévu d'événement récurrent, ni de planification avec un organisateur ou des participants. Par contre, les autres propriétés nous concernent directement.
Les bonnes pratiques
Pour la création d'un fichier iCalendar, on conseille :
- utiliser toujours un identifiant unique global
- fuseaux horaires : spécifier les fuseaux horaires pour éviter toute confusion
- encodage : utiliser UTF-8 et échapper les caractères spéciaux
- limiter les lignes à 75 caractères maximum (repliement avec CRLF)
- validation : tester avec des outils comme iCal Validator
Il existe un excellent viewer.
Il existe aussi un bon package, mais j'ai eu envie de coder cette partie en ne retenant que les fonctionnalités qui nous sont vraiment nécessaires.
Un service
On crée un service :

Avec ce code :
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class IcsCreator
{
/**
* Exporte les événements au format ICS
*/
public function create(Collection $events): string
{
return $this->generateIcsContent($events);
}
/**
* Génère le contenu du fichier ICS
*/
private function generateIcsContent(Collection $events): string
{
$ics = [];
// En-tête du fichier ICS (Utilisation de foldIcsLine pour chaque ligne)
$ics[] = $this->foldIcsLine('BEGIN:VCALENDAR');
$ics[] = $this->foldIcsLine('VERSION:2.0');
$ics[] = $this->foldIcsLine('PRODID:-//Mon Agenda//FR');
$ics[] = $this->foldIcsLine('CALSCALE:GREGORIAN');
$ics[] = $this->foldIcsLine('METHOD:PUBLISH');
$ics[] = $this->foldIcsLine('X-WR-CALNAME:Mon Agenda');
$ics[] = $this->foldIcsLine('X-WR-TIMEZONE:' . config('app.timezone'));
// Ajouter chaque événement
foreach ($events as $event) {
$ics[] = $this->foldIcsLine('BEGIN:VEVENT');
$ics[] = $this->foldIcsLine('UID:' . $event->id . '@' . request()->getHost());
$ics[] = $this->foldIcsLine('DTSTAMP:' . now()->format('Ymd\THis\Z'));
// Dates
if ($event->is_all_day) {
// Événement toute la journée (format DATE uniquement)
// Ces lignes sont courtes, mais on applique le folding par cohérence
$ics[] = $this->foldIcsLine('DTSTART;VALUE=DATE:' . Carbon::parse($event->start_date)->format('Ymd'));
$ics[] = $this->foldIcsLine('DTEND;VALUE=DATE:' . Carbon::parse($event->end_date)->addDay()->format('Ymd'));
} else {
// Événement avec heure (format DATE-TIME)
$ics[] = $this->foldIcsLine('DTSTART:' . Carbon::parse($event->start_date)->setTimezone('UTC')->format('Ymd\THis\Z'));
$ics[] = $this->foldIcsLine('DTEND:' . Carbon::parse($event->end_date)->setTimezone('UTC')->format('Ymd\THis\Z'));
}
// Titre (échapper les caractères spéciaux)
// L'appel à foldIcsLine est fait ici, sur la ligne complète PROPRIETE:VALEUR
$ics[] = $this->foldIcsLine('SUMMARY:' . $this->escapeIcsString($event->title));
// Description
if ($event->description) {
$ics[] = $this->foldIcsLine('DESCRIPTION:' . $this->escapeIcsString($event->description));
}
// Lieu
if ($event->location) {
$ics[] = $this->foldIcsLine('LOCATION:' . $this->escapeIcsString($event->location));
}
// Couleur (extension non-standard)
if ($event->color) {
$ics[] = $this->foldIcsLine('COLOR:' . $event->color);
}
// Dates de création et modification
$ics[] = $this->foldIcsLine('CREATED:' . Carbon::parse($event->created_at)->setTimezone('UTC')->format('Ymd\THis\Z'));
$ics[] = $this->foldIcsLine('LAST-MODIFIED:' . Carbon::parse($event->updated_at)->setTimezone('UTC')->format('Ymd\THis\Z'));
$ics[] = $this->foldIcsLine('END:VEVENT');
}
// Fin du fichier ICS
$ics[] = $this->foldIcsLine('END:VCALENDAR');
return implode("\r\n", $ics);
}
/**
* Échappe les caractères spéciaux pour le format ICS et normalise les espaces.
*/
private function escapeIcsString(string $string): string
{
// 1. Nettoyage et normalisation des espaces non standard (y compris U+00A0 ' ')
// Cela remplace tous les types d'espaces (fins, insécables, etc.) par l'espace ASCII standard (U+0020)
$string = preg_replace('/[\s\xA0\x{2000}-\x{200A}\x{202F}\x{205F}\x{3000}]/u', ' ', $string);
// 2. Remplacer les retours à la ligne par l'échappement ICS '\n'
$string = str_replace(["\r\n", "\n", "\r"], '\n', $string);
// 3. Échapper les caractères spéciaux ICS
$string = str_replace(',', '\,', $string);
$string = str_replace(';', '\;', $string);
return str_replace('\\', '\\\\', $string);
}
/**
* Plie les lignes longues selon la norme ICS (max 75 OCTETS).
*/
private function foldIcsLine(string $string): string
{
$maxLength = 75;
// Si la ligne est déjà assez courte, on la retourne telle quelle
if (strlen($string) <= $maxLength) {
return $string;
}
$lines = [];
$remaining = $string;
// Première ligne : jusqu'à 75 octets
$firstLine = substr($remaining, 0, $maxLength);
$remaining = substr($remaining, $maxLength);
// Pour les lignes suivantes, on ajoute un espace de début et on coupe à 74 octets
// (car on ajoute un espace au début de chaque ligne continuée)
$continuationMaxLength = $maxLength - 1;
while (strlen($remaining) > $continuationMaxLength) {
$lines[] = substr($remaining, 0, $continuationMaxLength);
$remaining = substr($remaining, $continuationMaxLength);
}
// Ajouter le reste
if (strlen($remaining) > 0) {
$lines[] = $remaining;
}
// Combiner avec les espaces de début pour les lignes continuées
if (!empty($lines)) {
return $firstLine . "\r\n " . implode("\r\n ", $lines);
}
return $firstLine;
}
}
La classe
Dans la classe Calendar, on ajoute une fonction pour l'exportation qui exploite notre service :
use App\Services\IcsCreator;
use Symfony\Component\HttpFoundation\StreamedResponse;
public function exportToIcs(IcsCreator $IcsCreator): StreamedResponse
{
$events = Auth::user()->events()->orderBy('start_date')->get();
$icsContent = $IcsCreator->create($events);
return response()->streamDownload(function () use ($icsContent) {
echo $icsContent;
}, 'mon-agenda-' . now()->format('Y-m-d') . '.ics', [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'attachment; filename="mon-agenda-' . now()->format('Y-m-d') . '.ics"',
]);
}
Le calendrier
En haut du calendrier, on ajoute le bouton pour l'exportation :
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">
Mon agenda
</h1>
<button
wire:click="exportToIcs"
wire:loading.attr="disabled"
wire:loading.class="opacity-75 cursor-not-allowed"
class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition shadow-sm disabled:bg-blue-400"
>
<span wire:loading wire:target="exportToIcs">
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
<span wire:loading.remove wire:target="exportToIcs">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</span>
<span wire:loading.remove wire:target="exportToIcs">Exporter (.ics)</span>
<span wire:loading wire:target="exportToIcs">Exportation...</span>
</button>
</div>

Action !
Il n'y a plus qu'à vérifier que ça fonctionne ! Vous devez obtenir ce genre de fichier :
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mon Agenda//FR
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Mon Agenda
X-WR-TIMEZONE:UTC
BEGIN:VEVENT
UID:18c82ecd-277d-4330-9b3f-46ea1f0a880b@agenda.oo
DTSTAMP:20251214T123942Z
DTSTART;VALUE=DATE:20251203
DTEND;VALUE=DATE:20251205
SUMMARY:Innovative multi-state service-desk a
DESCRIPTION:Eaque exercitationem incidunt repellat reprehenderit occaecati
sed. Repellat nam odio ipsa perferendis qui suscipit aliquid.
LOCATION:South Chandlerfurt
COLOR:#46d84a
CREATED:20251208T181247Z
LAST-MODIFIED:20251213T164700Z
END:VEVENT
BEGIN:VEVENT
UID:77ce2af8-ae31-48bd-aebd-ea5b5601b055@agenda.oo
DTSTAMP:20251214T123942Z
DTSTART:20251206T090000Z
DTEND:20251206T100000Z
SUMMARY:gggee
DESCRIPTION:dfg jj
LOCATION:d
COLOR:#ef4444
CREATED:20251213T164844Z
LAST-MODIFIED:20251213T165700Z
END:VEVENT
BEGIN:VEVENT
UID:0d3df947-bda7-4964-bb43-8029d9225fba@agenda.oo
DTSTAMP:20251214T123942Z
DTSTART:20251211T100000Z
DTEND:20251211T123000Z
SUMMARY:Up-sized didactic conglomeration
DESCRIPTION:Provident nostrum quidem ex delectus incidunt dignissimos nihil
. Possimus necessitatibus sed qui et eos ut fuga.
LOCATION:Lake Nadia
COLOR:#d83769
CREATED:20251208T181247Z
LAST-MODIFIED:20251213T164750Z
END:VEVENT
DTSTART;VALUE=DATE:20251230
DTEND;VALUE=DATE:20260106
SUMMARY:Total solution-oriented circuit
DESCRIPTION:Ipsum incidunt dolorem omnis doloribus ab. Ut distinctio harum
dignissimos illum debitis ut commodi.
LOCATION:Catharineside
COLOR:#46d84a
CREATED:20251208T181247Z
LAST-MODIFIED:20251208T181247Z
END:VEVENT
BEGIN:VEVENT
UID:e2b226d7-25d1-4555-bf22-024ed8ddd87c@agenda.oo
DTSTAMP:20251214T123942Z
DTSTART:20260103T130000Z
DTEND:20260103T160000Z
SUMMARY:Inverse well-modulated hardware
DESCRIPTION:Quis quia consequuntur et et maiores perspiciatis. Molestiae si
t repudiandae repellat quia eaque excepturi eum.
LOCATION:Raubury
COLOR:#46d84a
CREATED:20251208T181247Z
LAST-MODIFIED:20251208T181247Z
END:VEVENT
END:VCALENDAR
Vous pouvez l'envoyer en validation et normalement :

Conclusion
Nous voici arrivés au terme de cette série sur l'utilisation de FullCalendar avec Livewire !
Par bestmomo
Aucun commentaire