✨(import) add import modal
Synchronous for now, can be offloaded to worker later. Also lint the codebase
This commit is contained in:
@@ -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
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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 ?=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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": "*"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
234
docker/sabredav/src/CalendarSanitizerPlugin.php
Normal file
234
docker/sabredav/src/CalendarSanitizerPlugin.php
Normal 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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
278
docker/sabredav/src/ICSImportPlugin.php
Normal file
278
docker/sabredav/src/ICSImportPlugin.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
115
src/backend/core/services/import_service.py
Normal file
115
src/backend/core/services/import_service.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
1088
src/backend/core/tests/test_import_events.py
Normal file
1088
src/backend/core/tests/test_import_events.py
Normal file
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 |
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user