(import) add import modal

Synchronous for now, can be offloaded to worker later.

Also lint the codebase
This commit is contained in:
Sylvain Zimmer
2026-02-09 18:43:49 +01:00
parent 23a66f21e6
commit 3a0f64e791
30 changed files with 2476 additions and 121 deletions

View File

@@ -85,7 +85,7 @@ PHP SabreDAV server providing CalDAV protocol support, running against the share
- Backend API: http://localhost:8921
- CalDAV: http://localhost:8922
- Keycloak: http://localhost:8925
- PostgreSQL: 8912
- PostgreSQL: 8926
- Mailcatcher: http://localhost:1081
## Key Technologies

View File

@@ -108,6 +108,7 @@ build: cache ?= # --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
@$(MAKE) build-caldav cache=$(cache)
.PHONY: build
build-backend: cache ?=

View File

@@ -5,7 +5,7 @@ services:
postgresql:
image: postgres:15
ports:
- "8912:5432"
- "8926:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 1s
@@ -138,6 +138,11 @@ services:
env_file:
- env.d/development/caldav.defaults
- env.d/development/caldav.local
volumes:
- ./docker/sabredav/server.php:/var/www/sabredav/server.php
- ./docker/sabredav/src:/var/www/sabredav/src
- ./docker/sabredav/sql:/var/www/sabredav/sql
- ./docker/sabredav/init-database.sh:/usr/local/bin/init-database.sh
networks:
- default
- lasuite

View File

@@ -21,10 +21,8 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create application directory
WORKDIR /var/www/sabredav
# Copy composer files
# Copy composer files and install dependencies
COPY composer.json ./
# Install sabre/dav and dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy server configuration
@@ -51,7 +49,8 @@ RUN a2enmod rewrite headers \
RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini
&& echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \
&& echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/error-logging.ini
# Set permissions
RUN chown -R www-data:www-data /var/www/sabredav \

View File

@@ -2,9 +2,15 @@
"name": "calendars/sabredav-server",
"description": "sabre/dav CalDAV server for calendars",
"type": "project",
"repositories": [
{
"type": "vcs",
"url": "https://github.com/sylvinus/dav"
}
],
"require": {
"php": ">=8.1",
"sabre/dav": "^4.5",
"sabre/dav": "dev-master",
"ext-pdo": "*",
"ext-pdo_pgsql": "*"
},

View File

@@ -12,7 +12,9 @@ use Sabre\DAV;
use Calendars\SabreDav\AutoCreatePrincipalBackend;
use Calendars\SabreDav\HttpCallbackIMipPlugin;
use Calendars\SabreDav\ApiKeyAuthBackend;
use Calendars\SabreDav\CalendarSanitizerPlugin;
use Calendars\SabreDav\AttendeeNormalizerPlugin;
use Calendars\SabreDav\ICSImportPlugin;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
@@ -89,39 +91,56 @@ $server->addPlugin(new CalDAV\ICSExportPlugin());
$server->addPlugin(new DAV\Sharing\Plugin());
$server->addPlugin(new CalDAV\SharingPlugin());
// Debug logging for sharing requests
$server->on('method:POST', function($request) {
$contentType = $request->getHeader('Content-Type');
$path = $request->getPath();
$body = $request->getBodyAsString();
error_log("[sabre/dav] POST request received:");
error_log("[sabre/dav] Path: " . $path);
error_log("[sabre/dav] Content-Type: " . $contentType);
error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
// Reset body stream position
$request->setBody($body);
}, 50); // Priority 50 to run early
// Debug logging for POST requests - commented out to avoid PII in logs
// Uncomment for local debugging only, never in production.
// $server->on('method:POST', function($request) {
// $contentType = $request->getHeader('Content-Type');
// $path = $request->getPath();
// $body = $request->getBodyAsString();
// error_log("[sabre/dav] POST request received:");
// error_log("[sabre/dav] Path: " . $path);
// error_log("[sabre/dav] Content-Type: " . $contentType);
// error_log("[sabre/dav] Body: " . substr($body, 0, 1000));
// $request->setBody($body);
// }, 50);
//
// $server->on('afterMethod:POST', function($request, $response) {
// error_log("[sabre/dav] POST response status: " . $response->getStatus());
// $body = $response->getBodyAsString();
// if ($body) {
// error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
// }
// }, 50);
// Debug: Log when share plugin processes request
$server->on('afterMethod:POST', function($request, $response) {
error_log("[sabre/dav] POST response status: " . $response->getStatus());
$body = $response->getBodyAsString();
if ($body) {
error_log("[sabre/dav] POST response body: " . substr($body, 0, 500));
}
}, 50);
// Debug: Log exceptions
// Log unhandled exceptions
$server->on('exception', function($e) {
error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage());
error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString());
}, 50);
// Add calendar sanitizer plugin (priority 85, runs before all other calendar plugins)
// Strips inline binary attachments (Outlook/Exchange base64 images) and truncates
// oversized DESCRIPTION fields. Applies to ALL CalDAV writes (PUT from any client).
$sanitizerStripAttachments = getenv('SANITIZER_STRIP_BINARY_ATTACHMENTS') !== 'false';
$sanitizerMaxDescBytes = getenv('SANITIZER_MAX_DESCRIPTION_BYTES');
$sanitizerMaxDescBytes = ($sanitizerMaxDescBytes !== false) ? (int)$sanitizerMaxDescBytes : 102400;
$sanitizerMaxResourceSize = getenv('SANITIZER_MAX_RESOURCE_SIZE');
$sanitizerMaxResourceSize = ($sanitizerMaxResourceSize !== false) ? (int)$sanitizerMaxResourceSize : 1048576;
$server->addPlugin(new CalendarSanitizerPlugin(
$sanitizerStripAttachments,
$sanitizerMaxDescBytes,
$sanitizerMaxResourceSize
));
// Add attendee normalizer plugin to fix duplicate attendees issue
// This plugin normalizes attendee emails (lowercase) and deduplicates them
// when processing calendar objects, fixing issues with REPLY handling
$server->addPlugin(new AttendeeNormalizerPlugin());
// Add ICS import plugin for bulk event import from a single POST request
// Only accessible via the X-Calendars-Import header (backend-only)
$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey));
// Add custom IMipPlugin that forwards scheduling messages via HTTP callback
// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it
// The callback URL can be provided per-request via X-CalDAV-Callback-URL header

View File

@@ -60,12 +60,14 @@ class AttendeeNormalizerPlugin extends ServerPlugin
// Hook into calendar object creation and updates
// Priority 90 to run before most other plugins but after authentication
$server->on('beforeCreateFile', [$this, 'beforeWriteCalendarObject'], 90);
$server->on('beforeWriteContent', [$this, 'beforeWriteCalendarObject'], 90);
// Note: beforeCreateFile and beforeWriteContent have different signatures
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90);
}
/**
* Called before a calendar object is created or updated.
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*
* @param string $path The path to the file
* @param resource|string $data The data being written
@@ -73,18 +75,41 @@ class AttendeeNormalizerPlugin extends ServerPlugin
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeWriteCalendarObject($path, &$data, $parentNode = null, &$modified = false)
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*
* @param string $path The path to the file
* @param \Sabre\DAV\IFile $node The existing file node
* @param resource|string $data The data being written
* @param bool $modified Whether the data was modified
* @return void
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->processCalendarData($path, $data, $modified);
}
/**
* Process calendar data to normalize and deduplicate attendees.
*
* @param string $path The path to the file
* @param resource|string &$data The data being written (modified in place)
* @param bool &$modified Whether the data was modified
* @return void
*/
private function processCalendarData($path, &$data, &$modified)
{
// Only process .ics files in calendar collections
if (!preg_match('/\.ics$/i', $path)) {
return;
}
// Check if parent is a calendar collection
if ($parentNode && !($parentNode instanceof \Sabre\CalDAV\ICalendarObjectContainer)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {

View File

@@ -0,0 +1,234 @@
<?php
/**
* CalendarSanitizerPlugin - Sanitizes calendar data on all CalDAV writes.
*
* Applied to both new creates (PUT to new URI) and updates (PUT to existing URI).
* This covers events coming from any CalDAV client (Thunderbird, Apple Calendar,
* Outlook, etc.) as well as the bulk import plugin.
*
* Sanitizations:
* 1. Strip inline binary attachments (ATTACH;VALUE=BINARY / ENCODING=BASE64)
* These are typically Outlook/Exchange email signature images that bloat storage.
* URL-based attachments (e.g. Google Drive links) are preserved.
* 2. Truncate oversized text properties:
* - Long text fields (DESCRIPTION, X-ALT-DESC, COMMENT): configurable limit (default 100KB)
* - Short text fields (SUMMARY, LOCATION): fixed 1KB safety guardrail
* 3. Enforce max resource size (default 1MB) on the final serialized object.
* Returns HTTP 507 Insufficient Storage if exceeded after sanitization.
*
* Controlled by constructor parameters (read from env vars in server.php).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Exception\InsufficientStorage;
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;
class CalendarSanitizerPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var bool Whether to strip inline binary attachments */
private $stripBinaryAttachments;
/** @var int Max size in bytes for long text properties: DESCRIPTION, X-ALT-DESC, COMMENT (0 = no limit) */
private $maxDescriptionBytes;
/** @var int Max total resource size in bytes after sanitization (0 = no limit) */
private $maxResourceSize;
/** @var int Max size in bytes for short text properties: SUMMARY, LOCATION */
private const MAX_SHORT_TEXT_BYTES = 1024;
/** @var array Long text properties subject to $maxDescriptionBytes */
private const LONG_TEXT_PROPERTIES = ['DESCRIPTION', 'X-ALT-DESC', 'COMMENT'];
/** @var array Short text properties subject to MAX_SHORT_TEXT_BYTES */
private const SHORT_TEXT_PROPERTIES = ['SUMMARY', 'LOCATION'];
public function __construct(
bool $stripBinaryAttachments = true,
int $maxDescriptionBytes = 102400,
int $maxResourceSize = 1048576
) {
$this->stripBinaryAttachments = $stripBinaryAttachments;
$this->maxDescriptionBytes = $maxDescriptionBytes;
$this->maxResourceSize = $maxResourceSize;
}
public function getPluginName()
{
return 'calendar-sanitizer';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 85: run before AttendeeNormalizerPlugin (90) and CalDAV validation (100)
$server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 85);
$server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 85);
}
/**
* Called before a calendar object is created.
* Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified)
*/
public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Called before a calendar object is updated.
* Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified)
*/
public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false)
{
$this->sanitizeCalendarData($path, $data, $modified);
}
/**
* Sanitize raw calendar data from a beforeCreateFile/beforeWriteContent hook.
*/
private function sanitizeCalendarData($path, &$data, &$modified)
{
// Only process .ics files
if (!preg_match('/\.ics$/i', $path)) {
return;
}
try {
// Get the data as string
if (is_resource($data)) {
$dataStr = stream_get_contents($data);
rewind($data);
} else {
$dataStr = $data;
}
$vcalendar = Reader::read($dataStr);
if (!$vcalendar instanceof VCalendar) {
return;
}
if ($this->sanitizeVCalendar($vcalendar)) {
$data = $vcalendar->serialize();
$modified = true;
}
// Enforce max resource size after sanitization
$finalSize = is_string($data) ? strlen($data) : strlen($dataStr);
if ($this->maxResourceSize > 0 && $finalSize > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$finalSize} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
} catch (InsufficientStorage $e) {
// Re-throw size limit errors — these must reach the client as HTTP 507
throw $e;
} catch (\Exception $e) {
// Log other errors but don't block the request
error_log("[CalendarSanitizerPlugin] Error processing calendar object: " . $e->getMessage());
}
}
/**
* Sanitize a parsed VCalendar object in-place.
* Strips binary attachments and truncates oversized descriptions.
*
* Also called by ICSImportPlugin for direct DB writes that bypass
* the HTTP layer (and thus don't trigger beforeCreateFile hooks).
*
* @return bool True if the VCalendar was modified.
*/
public function sanitizeVCalendar(VCalendar $vcalendar)
{
$wasModified = false;
foreach ($vcalendar->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
// Strip inline binary attachments
if ($this->stripBinaryAttachments && isset($component->ATTACH)) {
$toRemove = [];
foreach ($component->select('ATTACH') as $attach) {
$valueParam = $attach->offsetGet('VALUE');
$encodingParam = $attach->offsetGet('ENCODING');
if (
($valueParam && strtoupper((string)$valueParam) === 'BINARY') ||
($encodingParam && strtoupper((string)$encodingParam) === 'BASE64')
) {
$toRemove[] = $attach;
}
}
foreach ($toRemove as $attach) {
$component->remove($attach);
$wasModified = true;
}
}
// Truncate oversized long text properties (DESCRIPTION, X-ALT-DESC, COMMENT)
if ($this->maxDescriptionBytes > 0) {
foreach (self::LONG_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > $this->maxDescriptionBytes) {
$component->{$prop} = substr($val, 0, $this->maxDescriptionBytes) . '...';
$wasModified = true;
}
}
}
}
// Truncate oversized short text properties (SUMMARY, LOCATION)
foreach (self::SHORT_TEXT_PROPERTIES as $prop) {
if (isset($component->{$prop})) {
$val = (string)$component->{$prop};
if (strlen($val) > self::MAX_SHORT_TEXT_BYTES) {
$component->{$prop} = substr($val, 0, self::MAX_SHORT_TEXT_BYTES) . '...';
$wasModified = true;
}
}
}
}
return $wasModified;
}
/**
* Check that a VCalendar's serialized size is within the max resource limit.
* Called by ICSImportPlugin for the direct DB write path.
*
* @throws InsufficientStorage if the serialized size exceeds the limit.
*/
public function checkResourceSize(VCalendar $vcalendar)
{
if ($this->maxResourceSize <= 0) {
return;
}
$size = strlen($vcalendar->serialize());
if ($size > $this->maxResourceSize) {
throw new InsufficientStorage(
"Calendar object size ({$size} bytes) exceeds limit ({$this->maxResourceSize} bytes)"
);
}
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Sanitizes calendar data (strips binary attachments, truncates descriptions)',
];
}
}

View File

@@ -0,0 +1,278 @@
<?php
/**
* ICSImportPlugin - Bulk import events from a multi-event ICS file.
*
* Accepts a single POST with raw ICS data and splits it into individual
* calendar objects using Sabre\VObject\Splitter\ICalendar. Each split
* VCALENDAR is validated/repaired and inserted directly via the CalDAV
* PDO backend, avoiding N HTTP round-trips from Python.
*
* The endpoint is gated by a dedicated X-Calendars-Import header so that
* only the Python backend can call it (not future proxied CalDAV clients).
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVBackend;
use Sabre\VObject;
class ICSImportPlugin extends ServerPlugin
{
/** @var Server */
protected $server;
/** @var CalDAVBackend */
private $caldavBackend;
/** @var string */
private $importApiKey;
public function __construct(CalDAVBackend $caldavBackend, string $importApiKey)
{
$this->caldavBackend = $caldavBackend;
$this->importApiKey = $importApiKey;
}
public function getPluginName()
{
return 'ics-import';
}
public function initialize(Server $server)
{
$this->server = $server;
// Priority 90: runs before the debug logger (50)
$server->on('method:POST', [$this, 'httpPost'], 90);
}
/**
* Handle POST requests with ?import query parameter.
*
* @return bool|null false to stop event propagation, null to let
* other handlers proceed.
*/
public function httpPost($request, $response)
{
// Only handle requests with ?import in the query string
$queryParams = $request->getQueryParameters();
if (!array_key_exists('import', $queryParams)) {
return;
}
// Verify the dedicated import header
$headerValue = $request->getHeader('X-Calendars-Import');
if (!$headerValue || $headerValue !== $this->importApiKey) {
$response->setStatus(403);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Forbidden: missing or invalid X-Calendars-Import header',
]));
return false;
}
// Resolve the calendar from the request path.
// getPath() returns a path relative to the base URI, e.g.
// "calendars/user@example.com/cal-uuid"
$path = $request->getPath();
$parts = explode('/', trim($path, '/'));
// Expect exactly: [calendars, <user>, <calendar-uri>]
if (count($parts) < 3 || $parts[0] !== 'calendars') {
error_log("[ICSImportPlugin] Invalid calendar path: " . $path);
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Invalid calendar path',
]));
return false;
}
$principalUser = urldecode($parts[1]);
$calendarUri = $parts[2];
$principalUri = 'principals/' . $principalUser;
// Look up calendarId by iterating the user's calendars
$calendarId = $this->resolveCalendarId($principalUri, $calendarUri);
if ($calendarId === null) {
$response->setStatus(404);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Calendar not found',
]));
return false;
}
// Read and parse the raw ICS body
$icsBody = $request->getBodyAsString();
if (empty($icsBody)) {
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Empty request body',
]));
return false;
}
try {
$vcal = VObject\Reader::read($icsBody);
} catch (\Exception $e) {
error_log("[ICSImportPlugin] Failed to parse ICS: " . $e->getMessage());
$response->setStatus(400);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'error' => 'Failed to parse ICS file',
]));
return false;
}
// Validate and auto-repair (fixes missing VALARM ACTION, etc.)
$vcal->validate(VObject\Component::REPAIR);
// Split by UID using the stream-based splitter
// The splitter expects a stream, so we wrap the serialized data
$stream = fopen('php://temp', 'r+');
fwrite($stream, $vcal->serialize());
rewind($stream);
$splitter = new VObject\Splitter\ICalendar($stream);
$totalEvents = 0;
$importedCount = 0;
$duplicateCount = 0;
$skippedCount = 0;
$errors = [];
while ($splitVcal = $splitter->getNext()) {
$totalEvents++;
try {
// Extract UID from the first VEVENT
$uid = null;
foreach ($splitVcal->VEVENT as $vevent) {
if (isset($vevent->UID)) {
$uid = (string)$vevent->UID;
break;
}
}
if (!$uid) {
$uid = \Sabre\DAV\UUIDUtil::getUUID();
}
// Sanitize event data (strip attachments, truncate descriptions)
// and enforce max resource size
$this->sanitizeAndCheckSize($splitVcal);
$objectUri = $uid . '.ics';
$data = $splitVcal->serialize();
$this->caldavBackend->createCalendarObject(
$calendarId,
$objectUri,
$data
);
$importedCount++;
} catch (\Exception $e) {
$msg = $e->getMessage();
$summary = '';
if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) {
$summary = (string)$splitVcal->VEVENT->SUMMARY;
}
// Duplicate key (SQLSTATE 23505) = event already exists
// "no valid instances" = dead recurring event (all occurrences excluded)
// Neither is actionable by the user, skip silently.
if (strpos($msg, '23505') !== false) {
$duplicateCount++;
} elseif (strpos($msg, 'valid instances') !== false) {
$skippedCount++;
} else {
$skippedCount++;
if (count($errors) < 10) {
$errors[] = [
'uid' => $uid ?? 'unknown',
'summary' => $summary,
'error' => $msg,
];
}
error_log(
"[ICSImportPlugin] Failed to import event "
. "uid=" . ($uid ?? 'unknown')
. " summary={$summary}: {$msg}"
);
}
}
}
fclose($stream);
error_log(
"[ICSImportPlugin] Import complete: "
. "{$importedCount} imported, "
. "{$duplicateCount} duplicates, "
. "{$skippedCount} failed "
. "out of {$totalEvents} total"
);
$response->setStatus(200);
$response->setHeader('Content-Type', 'application/json');
$response->setBody(json_encode([
'total_events' => $totalEvents,
'imported_count' => $importedCount,
'duplicate_count' => $duplicateCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
]));
return false;
}
/**
* Sanitize a split VCALENDAR before import and enforce max resource size.
*
* Delegates to CalendarSanitizerPlugin (if registered). Import bypasses
* the HTTP layer (uses createCalendarObject directly), so beforeCreateFile
* hooks don't fire — we must call the sanitizer explicitly.
*
* @throws \Exception if the sanitized object exceeds the max resource size.
*/
private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal)
{
$sanitizer = $this->server->getPlugin('calendar-sanitizer');
if ($sanitizer) {
$sanitizer->sanitizeVCalendar($vcal);
$sanitizer->checkResourceSize($vcal);
}
}
/**
* Resolve the internal calendar ID (the [calendarId, instanceId] pair)
* from a principal URI and calendar URI.
*
* @param string $principalUri e.g. "principals/user@example.com"
* @param string $calendarUri e.g. "a1b2c3d4-..."
* @return array|null The calendarId pair, or null if not found.
*/
private function resolveCalendarId(string $principalUri, string $calendarUri)
{
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar['uri'] === $calendarUri) {
return $calendar['id'];
}
}
return null;
}
public function getPluginInfo()
{
return [
'name' => $this->getPluginName(),
'description' => 'Bulk import events from a multi-event ICS file',
];
}
}

View File

@@ -9,3 +9,10 @@ CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
# Default callback URL for sending scheduling notifications (emails)
# Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header
CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback
# Calendar sanitizer: strip inline binary attachments (base64 images from Outlook/Exchange)
# Applies to all CalDAV writes (client PUTs + import)
SANITIZER_STRIP_BINARY_ATTACHMENTS=true
# Calendar sanitizer: max DESCRIPTION size in bytes (0 = no limit)
SANITIZER_MAX_DESCRIPTION_BYTES=102400
# Calendar sanitizer: max total resource size in bytes per calendar object (0 = no limit)
SANITIZER_MAX_RESOURCE_SIZE=1048576

View File

@@ -1,8 +1,5 @@
"""Client serializers for the calendars core app."""
import json
from datetime import timedelta
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@@ -130,10 +127,17 @@ class CalendarSerializer(serializers.ModelSerializer):
"description",
"is_default",
"is_visible",
"caldav_path",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_default",
"caldav_path",
"created_at",
"updated_at",
]
read_only_fields = ["id", "is_default", "created_at", "updated_at"]
class CalendarCreateSerializer(serializers.ModelSerializer):
@@ -198,7 +202,7 @@ class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):
return url
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer):
class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for creating a CalendarSubscriptionToken."""
caldav_path = serializers.CharField(max_length=512)

View File

@@ -4,32 +4,24 @@
import json
import logging
import re
from urllib.parse import unquote, urlparse
from urllib.parse import unquote
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models as db
from django.db import transaction
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.text import slugify
import rest_framework as drf
from corsheaders.middleware import (
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN,
)
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, mixins, status, viewsets
from rest_framework import mixins, status, viewsets
from rest_framework import response as drf_response
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from rest_framework_api_key.permissions import HasAPIKey
from core import enums, models
from core import models
from core.services.caldav_service import CalendarService
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
from . import permissions, serializers
@@ -295,12 +287,14 @@ class CalendarViewSet(
def get_queryset(self):
"""Return calendars owned by or shared with the current user."""
user = self.request.user
owned = models.Calendar.objects.filter(owner=user)
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
"calendar_id", flat=True
)
shared = models.Calendar.objects.filter(id__in=shared_ids)
return owned.union(shared).order_by("-is_default", "name")
return (
models.Calendar.objects.filter(db.Q(owner=user) | db.Q(id__in=shared_ids))
.distinct()
.order_by("-is_default", "name")
)
def get_serializer_class(self):
if self.action == "create":
@@ -327,7 +321,7 @@ class CalendarViewSet(
instance.delete()
@action(detail=True, methods=["patch"])
def toggle_visibility(self, request, pk=None):
def toggle_visibility(self, request, **kwargs):
"""Toggle calendar visibility."""
calendar = self.get_object()
@@ -356,7 +350,7 @@ class CalendarViewSet(
methods=["post"],
serializer_class=serializers.CalendarShareSerializer,
)
def share(self, request, pk=None):
def share(self, request, **kwargs):
"""Share calendar with another user."""
calendar = self.get_object()
@@ -396,6 +390,55 @@ class CalendarViewSet(
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
@action(
detail=True,
methods=["post"],
parser_classes=[MultiPartParser],
url_path="import_events",
url_name="import-events",
)
def import_events(self, request, **kwargs):
"""Import events from an ICS file into this calendar."""
calendar = self.get_object()
# Only the owner can import events
if calendar.owner != request.user:
return drf_response.Response(
{"error": "Only the owner can import events"},
status=status.HTTP_403_FORBIDDEN,
)
# Validate file presence
if "file" not in request.FILES:
return drf_response.Response(
{"error": "No file provided"},
status=status.HTTP_400_BAD_REQUEST,
)
uploaded_file = request.FILES["file"]
# Validate file size
if uploaded_file.size > MAX_FILE_SIZE:
return drf_response.Response(
{"error": "File too large. Maximum size is 10 MB."},
status=status.HTTP_400_BAD_REQUEST,
)
ics_data = uploaded_file.read()
service = ICSImportService()
result = service.import_events(request.user, calendar, ics_data)
response_data = {
"total_events": result.total_events,
"imported_count": result.imported_count,
"duplicate_count": result.duplicate_count,
"skipped_count": result.skipped_count,
}
if result.errors:
response_data["errors"] = result.errors
return drf_response.Response(response_data, status=status.HTTP_200_OK)
class SubscriptionTokenViewSet(viewsets.GenericViewSet):
"""
@@ -535,6 +578,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
if request.method == "GET":
serializer = self.get_serializer(token, context={"request": request})
return drf_response.Response(serializer.data)
elif request.method == "DELETE":
# DELETE
token.delete()
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -28,7 +28,7 @@ class CalDAVProxyView(View):
Authentication is handled via session cookies instead.
"""
def dispatch(self, request, *args, **kwargs):
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912 # pylint: disable=too-many-branches
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
@@ -247,7 +247,8 @@ class CalDAVSchedulingCallbackView(View):
)
return HttpResponse(
status=400,
content="Missing required headers: X-CalDAV-Sender, X-CalDAV-Recipient, X-CalDAV-Method",
content="Missing required headers: X-CalDAV-Sender, "
"X-CalDAV-Recipient, X-CalDAV-Method",
content_type="text/plain",
)
@@ -291,7 +292,7 @@ class CalDAVSchedulingCallbackView(View):
content="OK",
content_type="text/plain",
)
else:
logger.error(
"Failed to send calendar %s email: %s -> %s",
method,
@@ -304,7 +305,7 @@ class CalDAVSchedulingCallbackView(View):
content_type="text/plain",
)
except Exception as e:
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,

View File

@@ -99,7 +99,7 @@ class CalDAVClient:
except NotFoundError:
logger.warning("Calendar not found at path: %s", calendar_path)
return None
except Exception as e:
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
return None
@@ -186,6 +186,25 @@ class CalDAVClient:
logger.error("Failed to get events from CalDAV server: %s", str(e))
raise
def create_event_raw(self, user, calendar_path: str, ics_data: str) -> str:
"""
Create an event in CalDAV server from raw ICS data.
The ics_data should be a complete VCALENDAR string.
Returns the event UID.
"""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
calendar = client.calendar(url=calendar_url)
try:
event = calendar.save_event(ics_data)
event_uid = str(event.icalendar_component.get("uid", ""))
logger.info("Created event in CalDAV server: %s", event_uid)
return event_uid
except Exception as e:
logger.error("Failed to create event in CalDAV server: %s", str(e))
raise
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
"""
Create a new event in CalDAV server.
@@ -353,7 +372,7 @@ class CalDAVClient:
event_data["end"] = event_data["end"].strftime("%Y%m%d")
return event_data if event_data.get("uid") else None
except Exception as e:
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
logger.warning("Failed to parse event: %s", str(e))
return None

View File

@@ -17,6 +17,10 @@ from email import encoders
from email.mime.base import MIMEBase
from typing import Optional
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
# French month and day names for date formatting
FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
FRENCH_MONTHS = [
@@ -35,15 +39,11 @@ FRENCH_MONTHS = [
"décembre",
]
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
@dataclass
class EventDetails:
class EventDetails: # pylint: disable=too-many-instance-attributes
"""Parsed event details from iCalendar data."""
uid: str
@@ -127,10 +127,9 @@ class ICalendarParser:
if params_str:
# Split by ; but not within quotes
param_matches = re.findall(r";([^=]+)=([^;]+)", params_str)
for param_name, param_value in param_matches:
for param_name, raw_value in param_matches:
# Remove quotes if present
param_value = param_value.strip('"')
params[param_name.upper()] = param_value
params[param_name.upper()] = raw_value.strip('"')
return value, params
@@ -160,13 +159,17 @@ class ICalendarParser:
elif tzid:
# Has timezone info - try to convert using zoneinfo
try:
from zoneinfo import ZoneInfo
from zoneinfo import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ZoneInfo,
)
tz = ZoneInfo(tzid)
dt = dt.replace(tzinfo=tz)
except Exception:
except (KeyError, ValueError):
# If timezone conversion fails, keep as naive datetime
pass
logger.debug(
"Unknown timezone %s, keeping naive datetime", tzid
)
return dt
except ValueError:
continue
@@ -175,7 +178,9 @@ class ICalendarParser:
return None
@classmethod
def parse(cls, icalendar: str, recipient_email: str) -> Optional[EventDetails]:
def parse( # pylint: disable=too-many-locals,too-many-branches
cls, icalendar: str, recipient_email: str
) -> Optional[EventDetails]:
"""
Parse iCalendar data and extract event details.
@@ -272,12 +277,12 @@ class ICalendarParser:
raw_icalendar=icalendar,
)
except Exception as e:
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception("Failed to parse iCalendar data: %s", e)
return None
class CalendarInvitationService:
class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"""
Service for sending calendar invitation emails.
@@ -369,7 +374,7 @@ class CalendarInvitationService:
event_uid=event.uid,
)
except Exception as e:
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to send calendar invitation to %s: %s", recipient, e
)
@@ -456,7 +461,7 @@ class CalendarInvitationService:
return icalendar_data
def _send_email(
def _send_email( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
from_email: str,
to_email: str,
@@ -506,7 +511,7 @@ class CalendarInvitationService:
"Content-Type", f"text/calendar; charset=utf-8; method={ics_method}"
)
ics_attachment.add_header(
"Content-Disposition", f'attachment; filename="invite.ics"'
"Content-Disposition", 'attachment; filename="invite.ics"'
)
# Attach the ICS file
@@ -524,7 +529,7 @@ class CalendarInvitationService:
)
return True
except Exception as e:
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(
"Failed to send calendar invitation email to %s: %s", to_email, e
)

View File

@@ -0,0 +1,115 @@
"""Service for importing events from ICS files."""
import logging
from dataclasses import dataclass, field
from django.conf import settings
import requests
logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
@dataclass
class ImportResult:
"""Result of an ICS import operation.
errors contains event names (summaries) of failed events,
at most 10 entries.
"""
total_events: int = 0
imported_count: int = 0
duplicate_count: int = 0
skipped_count: int = 0
errors: list[str] = field(default_factory=list)
class ICSImportService:
"""Service for importing events from ICS data into a CalDAV calendar.
Sends the raw ICS file in a single POST to the SabreDAV ICS import
plugin which handles splitting, validation/repair, and direct DB
insertion.
"""
def __init__(self):
self.base_url = settings.CALDAV_URL.rstrip("/")
def import_events(self, user, calendar, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar.
Sends the raw ICS bytes to SabreDAV's ?import endpoint which
handles all ICS parsing, splitting by UID, VALARM repair, and
per-event insertion.
"""
result = ImportResult()
# caldav_path already includes the base URI prefix
# e.g. /api/v1.0/caldav/calendars/user@example.com/uuid/
url = f"{self.base_url}{calendar.caldav_path}?import"
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
return result
headers = {
"Content-Type": "text/calendar",
"X-Api-Key": outbound_api_key,
"X-Forwarded-User": user.email,
"X-Calendars-Import": outbound_api_key,
}
try:
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
response = requests.post(
url, data=ics_data, headers=headers, timeout=timeout
)
except requests.RequestException as exc:
logger.error("Failed to reach SabreDAV import endpoint: %s", exc)
result.errors.append("Failed to reach CalDAV server")
return result
if response.status_code != 200:
logger.error(
"SabreDAV import returned %s: %s",
response.status_code,
response.text[:500],
)
result.errors.append("CalDAV server error")
return result
try:
data = response.json()
except ValueError:
logger.error("Invalid JSON from SabreDAV import: %s", response.text[:500])
result.errors.append("Invalid response from CalDAV server")
return result
result.total_events = data.get("total_events", 0)
result.imported_count = data.get("imported_count", 0)
result.duplicate_count = data.get("duplicate_count", 0)
result.skipped_count = data.get("skipped_count", 0)
# SabreDAV returns structured errors {uid, summary, error}.
# Log full details server-side, expose only event names to the frontend.
for err in data.get("errors", []):
if isinstance(err, dict):
logger.warning(
"Import failed for uid=%s summary=%s: %s",
err.get("uid", "?"),
err.get("summary", "?"),
err.get("error", "?"),
)
result.errors.append(
err.get("summary") or err.get("uid", "Unknown event")
)
else:
result.errors.append(str(err))
return result

View File

@@ -16,7 +16,7 @@ User = get_user_model()
@receiver(post_save, sender=User)
def provision_default_calendar(sender, instance, created, **kwargs):
def provision_default_calendar(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Auto-provision a default calendar when a new user is created.
"""
@@ -35,7 +35,7 @@ def provision_default_calendar(sender, instance, created, **kwargs):
service = CalendarService()
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e:
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# In tests, CalDAV server may not be available, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()

View File

@@ -26,7 +26,7 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler):
self.callback_data = callback_data
super().__init__(*args, **kwargs)
def do_POST(self):
def do_POST(self): # pylint: disable=invalid-name
"""Handle POST requests (scheduling callbacks)."""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
@@ -44,9 +44,8 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(b"OK")
def log_message(self, format, *args):
def log_message(self, format, *args): # pylint: disable=redefined-builtin
"""Suppress default logging."""
pass
def create_test_server() -> tuple:
@@ -79,7 +78,9 @@ class TestCalDAVScheduling:
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_scheduling_callback_received_when_creating_event_with_attendee(self):
def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements
self,
):
"""Test that creating an event with an attendee triggers scheduling callback.
This test verifies that when an event is created with an attendee via CalDAV,
@@ -114,7 +115,7 @@ class TestCalDAVScheduling:
try:
test_socket.connect(("127.0.0.1", port))
test_socket.close()
except Exception as e:
except OSError as e:
pytest.fail(f"Test server failed to start on port {port}: {e}")
# Use the named test container hostname
@@ -124,7 +125,7 @@ class TestCalDAVScheduling:
try:
# Create an event with an attendee
client = service.caldav._get_client(organizer)
client = service.caldav._get_client(organizer) # pylint: disable=protected-access
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
# Add custom callback URL header to the client
@@ -159,7 +160,7 @@ END:VEVENT
END:VCALENDAR"""
# Save event to trigger scheduling
event = caldav_calendar.save_event(ical_content)
caldav_calendar.save_event(ical_content)
# Give the callback a moment to be called (scheduling may be async)
# sabre/dav processes scheduling synchronously during the request
@@ -167,21 +168,24 @@ END:VCALENDAR"""
# Verify callback was called
assert callback_data["called"], (
"Scheduling callback was not called when creating event with attendee. "
"This may indicate that sabre/dav's scheduling plugin is not working correctly. "
"Scheduling callback was not called when creating event "
"with attendee. This may indicate that sabre/dav's "
"scheduling plugin is not working correctly. "
"Check CalDAV server logs for scheduling errors."
)
# Verify callback request details
request_data = callback_data["request_data"]
# pylint: disable=unsubscriptable-object
request_data: dict = callback_data["request_data"]
assert request_data is not None
# Verify API key authentication
api_key = request_data["headers"].get("X-Api-Key", "")
expected_key = settings.CALDAV_INBOUND_API_KEY
assert expected_key and secrets.compare_digest(api_key, expected_key), (
f"Callback request missing or invalid X-Api-Key header. "
f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..."
"Callback request missing or invalid X-Api-Key header. "
f"Expected: {expected_key[:10]}..., "
f"Got: {api_key[:10] if api_key else 'None'}..."
)
# Verify scheduling headers
@@ -238,7 +242,7 @@ END:VCALENDAR"""
except NotFoundError:
pytest.skip("Calendar not found - CalDAV server may not be running")
except Exception as e:
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
pytest.fail(f"Failed to create event with attendee: {str(e)}")
finally:
# Shutdown server

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -53,3 +53,37 @@ export const fetchAPI = async (
throw new APIError(response.status);
};
export const fetchAPIFormData = async (
input: string,
init?: RequestInit & { params?: Record<string, string | number> },
) => {
const apiUrl = new URL(`${baseApiUrl("1.0")}${input}`);
if (init?.params) {
Object.entries(init.params).forEach(([key, value]) => {
apiUrl.searchParams.set(key, String(value));
});
}
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
...init,
credentials: "include",
headers: {
...init?.headers,
...(csrfToken && { "X-CSRFToken": csrfToken }),
},
});
if (response.ok) {
return response;
}
const data = await response.text();
if (isJson(data)) {
throw new APIError(response.status, JSON.parse(data));
}
throw new APIError(response.status);
};

View File

@@ -2,7 +2,7 @@
* API functions for calendar operations.
*/
import { fetchAPI } from "@/features/api/fetchApi";
import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi";
export interface Calendar {
id: string;
@@ -11,6 +11,7 @@ export interface Calendar {
description: string;
is_default: boolean;
is_visible: boolean;
caldav_path: string;
owner: string;
}
@@ -204,3 +205,34 @@ export const deleteSubscriptionToken = async (
);
};
/**
* Result of an ICS import operation.
*/
export interface ImportEventsResult {
total_events: number;
imported_count: number;
duplicate_count: number;
skipped_count: number;
errors?: string[];
}
/**
* Import events from an ICS file into a calendar.
*/
export const importEventsApi = async (
calendarId: string,
file: File,
): Promise<ImportEventsResult> => {
const formData = new FormData();
formData.append("file", file);
const response = await fetchAPIFormData(
`calendars/${calendarId}/import_events/`,
{
method: "POST",
body: formData,
},
);
return response.json();
};

View File

@@ -15,6 +15,7 @@ export const CalendarItemMenu = ({
onOpenChange,
onEdit,
onDelete,
onImport,
onSubscription,
}: CalendarItemMenuProps) => {
const { t } = useTranslation();
@@ -28,6 +29,14 @@ export const CalendarItemMenu = ({
},
];
if (onImport) {
items.push({
label: t("calendar.list.import"),
icon: <span className="material-icons">upload_file</span>,
callback: onImport,
});
}
if (onSubscription) {
items.push({
label: t("calendar.list.subscription"),
@@ -43,7 +52,7 @@ export const CalendarItemMenu = ({
});
return items;
}, [t, onEdit, onDelete, onSubscription]);
}, [t, onEdit, onDelete, onImport, onSubscription]);
return (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>

View File

@@ -389,3 +389,110 @@
padding-top: 0.5rem;
}
}
// ============================================================================
// Import Events Modal Styles
// ============================================================================
.import-events-modal {
display: flex;
flex-direction: column;
gap: 1rem;
&__description {
font-size: 0.875rem;
color: var(--c--theme--colors--greyscale-600);
margin: 0;
line-height: 1.5;
}
&__file-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
&__filename {
font-size: 0.875rem;
color: var(--c--theme--colors--greyscale-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__error {
padding: 12px 16px;
background-color: #fef2f2;
color: #dc2626;
border-radius: 8px;
font-size: 0.875rem;
border: 1px solid #fecaca;
}
&__result {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__result-header {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--c--theme--colors--greyscale-800);
}
&__stats {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__stat {
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
.material-icons {
font-size: 1.125rem;
}
&--success {
color: #16a34a;
}
&--neutral {
color: var(--c--theme--colors--greyscale-500);
}
&--warning {
color: #d97706;
}
}
&__errors {
font-size: 0.875rem;
color: var(--c--theme--colors--greyscale-600);
summary {
cursor: pointer;
user-select: none;
padding: 0.25rem 0;
}
ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
li {
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: #d97706;
}
}
}
}

View File

@@ -2,22 +2,26 @@
* CalendarList component - List of calendars with visibility toggles.
*/
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Calendar } from "../../types";
import { useCalendarContext } from "../../contexts";
import { CalendarModal } from "./CalendarModal";
import { DeleteConfirmModal } from "./DeleteConfirmModal";
import { ImportEventsModal } from "./ImportEventsModal";
import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem";
import { useCalendarListState } from "./hooks/useCalendarListState";
import type { CalendarListProps } from "./types";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import { Calendar as DjangoCalendar, getCalendars } from "../../api";
export const CalendarList = ({ calendars }: CalendarListProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const {
davCalendars,
visibleCalendarUrls,
@@ -26,6 +30,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
updateCalendar,
deleteCalendar,
shareCalendar,
calendarRef,
} = useCalendarContext();
const {
@@ -108,6 +113,68 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
// Ensure calendars is an array
const calendarsArray = Array.isArray(calendars) ? calendars : [];
// Import modal state
const [importModal, setImportModal] = useState<{
isOpen: boolean;
calendarId: string | null;
calendarName: string;
}>({ isOpen: false, calendarId: null, calendarName: "" });
const handleOpenImportModal = async (davCalendar: CalDavCalendar) => {
try {
// Extract the CalDAV path from the calendar URL
const url = new URL(davCalendar.url);
const pathParts = url.pathname.split("/").filter(Boolean);
const calendarsIndex = pathParts.findIndex((part) => part === "calendars");
if (calendarsIndex === -1 || pathParts.slice(calendarsIndex).length < 3) {
console.error("Invalid calendar URL format:", davCalendar.url);
return;
}
const caldavPath = "/" + pathParts.slice(calendarsIndex).join("/") + "/";
// Find the matching Django Calendar by caldav_path
const caldavApiRoot = "/api/v1.0/caldav";
const normalize = (p: string) =>
decodeURIComponent(p).replace(caldavApiRoot, "").replace(/\/+$/, "");
const findCalendar = (cals: DjangoCalendar[]) =>
cals.find((cal) => normalize(cal.caldav_path) === normalize(caldavPath));
// Fetch fresh Django calendars to ensure newly created calendars are included.
// Uses React Query cache, forcing a refetch if stale.
const freshCalendars = await queryClient.fetchQuery({
queryKey: ["calendars"],
queryFn: getCalendars,
});
const djangoCalendar = findCalendar(freshCalendars);
if (!djangoCalendar) {
console.error("No matching Django calendar found for path:", caldavPath);
return;
}
setImportModal({
isOpen: true,
calendarId: djangoCalendar.id,
calendarName: davCalendar.displayName || "",
});
} catch (error) {
console.error("Failed to parse calendar URL:", error);
}
};
const handleCloseImportModal = () => {
setImportModal({ isOpen: false, calendarId: null, calendarName: "" });
};
const handleImportSuccess = useCallback(() => {
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
}, [calendarRef]);
// Use translation key for shared marker
const sharedMarker = t('calendar.list.shared');
@@ -160,6 +227,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
onMenuToggle={handleMenuToggle}
onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteModal}
onImport={handleOpenImportModal}
onSubscription={handleOpenSubscriptionModal}
onCloseMenu={handleCloseMenu}
/>
@@ -231,6 +299,16 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
onClose={handleCloseSubscriptionModal}
/>
)}
{importModal.isOpen && importModal.calendarId && (
<ImportEventsModal
isOpen={importModal.isOpen}
calendarId={importModal.calendarId}
calendarName={importModal.calendarName}
onClose={handleCloseImportModal}
onImportSuccess={handleImportSuccess}
/>
)}
</>
);
};

View File

@@ -23,6 +23,7 @@ export const CalendarListItem = ({
onMenuToggle,
onEdit,
onDelete,
onImport,
onSubscription,
onCloseMenu,
}: CalendarListItemProps) => {
@@ -55,6 +56,9 @@ export const CalendarListItem = ({
}
onEdit={() => onEdit(calendar)}
onDelete={() => onDelete(calendar)}
onImport={
onImport ? () => onImport(calendar) : undefined
}
onSubscription={
onSubscription ? () => onSubscription(calendar) : undefined
}

View File

@@ -0,0 +1,173 @@
/**
* ImportEventsModal component.
* Allows users to import events from an ICS file into a calendar.
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
import { useImportEvents } from "../../hooks/useCalendars";
import type { ImportEventsResult } from "../../api";
interface ImportEventsModalProps {
isOpen: boolean;
calendarId: string;
calendarName: string;
onClose: () => void;
onImportSuccess?: () => void;
}
export const ImportEventsModal = ({
isOpen,
calendarId,
calendarName,
onClose,
onImportSuccess,
}: ImportEventsModalProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [result, setResult] = useState<ImportEventsResult | null>(null);
const importMutation = useImportEvents();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
setResult(null);
};
const handleImport = async () => {
if (!selectedFile) return;
const importResult = await importMutation.mutateAsync({
calendarId,
file: selectedFile,
});
setResult(importResult);
if (importResult.imported_count > 0) {
onImportSuccess?.();
}
};
const handleClose = () => {
setSelectedFile(null);
setResult(null);
importMutation.reset();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
const hasResult = result !== null;
const hasErrors = result && result.errors && result.errors.length > 0;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
size={ModalSize.MEDIUM}
title={t("calendar.importEvents.title")}
rightActions={
hasResult ? (
<Button color="brand" onClick={handleClose}>
{t("calendar.subscription.close")}
</Button>
) : (
<>
<Button color="neutral" onClick={handleClose}>
{t("calendar.event.cancel")}
</Button>
<Button
color="brand"
onClick={handleImport}
disabled={!selectedFile || importMutation.isPending}
>
{importMutation.isPending
? <Spinner size="sm" />
: t("calendar.importEvents.import")}
</Button>
</>
)
}
>
<div className="import-events-modal">
<p className="import-events-modal__description">
{t("calendar.importEvents.description", { name: calendarName })}
</p>
{!hasResult && (
<div className="import-events-modal__file-section">
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical,.ifb,.icalendar,text/calendar"
onChange={handleFileChange}
style={{ display: "none" }}
/>
<Button
color="neutral"
onClick={() => fileInputRef.current?.click()}
icon={
<span className="material-icons">upload_file</span>
}
>
{t("calendar.importEvents.selectFile")}
</Button>
{selectedFile && (
<span className="import-events-modal__filename">
{selectedFile.name}
</span>
)}
</div>
)}
{importMutation.isError && !hasResult && (
<div className="import-events-modal__error">
{t("calendar.importEvents.error")}
</div>
)}
{hasResult && (
<div className="import-events-modal__result">
<p className="import-events-modal__result-header">
{t("calendar.importEvents.resultHeader")}
</p>
<ul className="import-events-modal__stats">
{result.imported_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--success">
<span className="material-icons">check_circle</span>
<span><strong>{result.imported_count}</strong> {t("calendar.importEvents.imported")}</span>
</li>
)}
{result.duplicate_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--neutral">
<span className="material-icons">content_copy</span>
<span><strong>{result.duplicate_count}</strong> {t("calendar.importEvents.duplicates")}</span>
</li>
)}
{result.skipped_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--warning">
<span className="material-icons">warning_amber</span>
<span><strong>{result.skipped_count}</strong> {t("calendar.importEvents.skipped")}</span>
</li>
)}
</ul>
{hasErrors && (
<details className="import-events-modal__errors">
<summary>{t("calendar.importEvents.errorDetails")}</summary>
<ul>
{result.errors!.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</details>
)}
</div>
)}
</div>
</Modal>
);
};

View File

@@ -25,6 +25,7 @@ export interface CalendarItemMenuProps {
onOpenChange: (isOpen: boolean) => void;
onEdit: () => void;
onDelete: () => void;
onImport?: () => void;
onSubscription?: () => void;
}
@@ -50,6 +51,7 @@ export interface CalendarListItemProps {
onMenuToggle: (url: string) => void;
onEdit: (calendar: CalDavCalendar) => void;
onDelete: (calendar: CalDavCalendar) => void;
onImport?: (calendar: CalDavCalendar) => void;
onSubscription?: (calendar: CalDavCalendar) => void;
onCloseMenu: () => void;
}

View File

@@ -12,6 +12,8 @@ import {
getCalendars,
getSubscriptionToken,
GetSubscriptionTokenResult,
importEventsApi,
ImportEventsResult,
SubscriptionToken,
SubscriptionTokenError,
SubscriptionTokenParams,
@@ -132,3 +134,21 @@ export const useDeleteSubscriptionToken = () => {
},
});
};
/**
* Hook to import events from an ICS file.
*/
export const useImportEvents = () => {
const queryClient = useQueryClient();
return useMutation<
ImportEventsResult,
Error,
{ calendarId: string; file: File }
>({
mutationFn: ({ calendarId, file }) => importEventsApi(calendarId, file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
},
});
};

View File

@@ -182,7 +182,21 @@
"showCalendar": "Show calendar",
"edit": "Edit",
"delete": "Delete",
"subscription": "Subscription URL"
"import": "Import events",
"subscription": "Subscription URL",
"options": "Options"
},
"importEvents": {
"title": "Import events",
"description": "Import events from an ICS file into \"{{name}}\".",
"selectFile": "Select file",
"import": "Import",
"resultHeader": "Import results",
"imported": "events imported",
"duplicates": "events already existed",
"skipped": "events skipped (unsupported format)",
"error": "An error occurred during import. Please try again.",
"errorDetails": "Error details"
},
"subscription": {
"title": "Calendar Subscription URL",
@@ -793,7 +807,21 @@
"showCalendar": "Afficher le calendrier",
"edit": "Modifier",
"delete": "Supprimer",
"subscription": "URL d'abonnement"
"import": "Importer des événements",
"subscription": "URL d'abonnement",
"options": "Options"
},
"importEvents": {
"title": "Importer des événements",
"description": "Importer des événements depuis un fichier ICS dans \"{{name}}\".",
"selectFile": "Choisir un fichier",
"import": "Importer",
"resultHeader": "Résultats de l'import",
"imported": "événements importés",
"duplicates": "événements existaient déjà",
"skipped": "événements ignorés (format non supporté)",
"error": "Une erreur est survenue lors de l'importation. Veuillez réessayer.",
"errorDetails": "Détails des erreurs"
},
"subscription": {
"title": "URL d'abonnement au calendrier",
@@ -1151,7 +1179,21 @@
"showCalendar": "Agenda tonen",
"edit": "Bewerken",
"delete": "Verwijderen",
"subscription": "Abonnements-URL"
"import": "Evenementen importeren",
"subscription": "Abonnements-URL",
"options": "Opties"
},
"importEvents": {
"title": "Evenementen importeren",
"description": "Importeer evenementen vanuit een ICS-bestand in \"{{name}}\".",
"selectFile": "Bestand kiezen",
"import": "Importeren",
"resultHeader": "Importresultaten",
"imported": "evenementen geïmporteerd",
"duplicates": "evenementen bestonden al",
"skipped": "evenementen overgeslagen (niet-ondersteund formaat)",
"error": "Er is een fout opgetreden tijdens het importeren. Probeer het opnieuw.",
"errorDetails": "Foutdetails"
},
"subscription": {
"title": "Agenda-abonnements-URL",