*/ private array $warnings = []; public function __construct( private readonly Container $container, ) { } /** * Detect the CSV format from header columns. * * Google uses "All Day Event" (capital E), Outlook uses "All day event" (lowercase e). * * @param array $headers The first row of the CSV file. */ public function detect_format( array $headers ): CsvFormat { $trimmed = array_map( 'trim', $headers ); // Google: exact match on first 9 columns if ( count( $trimmed ) >= 9 && array_slice( $trimmed, 0, 9 ) === self::GOOGLE_HEADERS ) { return CsvFormat::GOOGLE; } // Outlook: match first 6 columns (case-sensitive "All day event" with lowercase e) if ( count( $trimmed ) >= 6 && array_slice( $trimmed, 0, 6 ) === self::OUTLOOK_DETECTION_HEADERS ) { return CsvFormat::OUTLOOK; } return CsvFormat::GENERIC; } /** * Parse CSV content into rows. * * @param string $content Raw CSV file content. * @return array> Array of rows, each row being an array of column values. */ public function parse_csv( string $content ): array { // Strip UTF-8 BOM if ( str_starts_with( $content, "\xEF\xBB\xBF" ) ) { $content = substr( $content, 3 ); } $rows = []; $lines = str_getcsv( $content, "\n" ); foreach ( $lines as $line ) { if ( ! is_string( $line ) || trim( $line ) === '' ) { continue; } $parsed = str_getcsv( $line ); if ( $parsed !== [ null ] ) { $rows[] = array_map( fn( $v ) => (string) ( $v ?? '' ), $parsed ); } } return $rows; } /** * Import events from CSV content. * * @param string $csv_content Raw CSV file content. * @param string $target_login User login to import events under. * @param CsvFormat $format Detected or selected format. * @param array $column_map Column mapping for generic format (field name => column index). * @return ImportResult */ public function import( string $csv_content, string $target_login, CsvFormat $format, array $column_map = [] ): ImportResult { $this->warnings = []; $rows = $this->parse_csv( $csv_content ); if ( count( $rows ) < 2 ) { return new ImportResult( 0, 0, [ [ 'line' => 0, 'message' => __( 'CSV file is empty or has no data rows.', 'agenticdaisy-calendar' ), ], ] ); } $headers = array_shift( $rows ); $map = $format === CsvFormat::GENERIC ? $column_map : $this->build_known_format_mapping( $format, $headers ); // Validate required columns if ( ! isset( $map['title'] ) || ! isset( $map['start_date'] ) ) { return new ImportResult( 0, 0, [ [ 'line' => 0, 'message' => __( 'CSV must have at least Title and Start Date columns.', 'agenticdaisy-calendar' ), ], ] ); } $raw_max = \get_option( 'webcal_import_max_events', 5000 ); $max_events = is_numeric( $raw_max ) ? (int) $raw_max : 5000; $event_service = $this->container->get_event_service(); $user_repo = $this->container->get_user_repository(); $user = $user_repo->findByLogin( $target_login ); if ( ! $user ) { // Shared calendar — create pseudo-User (same pattern as iCal import) $user = new User( login: $target_login, firstName: $target_login, lastName: '', email: $target_login . '@system.local', isAdmin: false, isEnabled: true, ); } $imported = 0; $skipped = 0; foreach ( $rows as $index => $row ) { $row_num = $index + 2; // 1-based, +1 for header row if ( $imported >= $max_events ) { $this->warnings[] = [ 'line' => $row_num, 'message' => sprintf( // translators: %d is the max events limit. __( 'Import limit reached (%d events). Remaining rows skipped.', 'agenticdaisy-calendar' ), $max_events ), ]; break; } $event = $this->row_to_event( $row, $map, $target_login, $row_num ); if ( $event === null ) { ++$skipped; continue; } try { $event_service->createEvent( $event, $user ); $this->assign_category_from_row( $row, $map, $event, $target_login, $user, $row_num ); ++$imported; } catch ( \Exception $e ) { $this->warnings[] = [ 'line' => $row_num, 'message' => sprintf( // translators: 1: row number, 2: error message. __( 'Row %1$d: %2$s', 'agenticdaisy-calendar' ), $row_num, $e->getMessage() ), ]; ++$skipped; } } return new ImportResult( $imported, $skipped, $this->warnings ); } /** * Build column mapping for a known format. * * @param CsvFormat $format The detected format. * @param array $headers Header row from CSV. * @return array Field name => column index. */ private function build_known_format_mapping( CsvFormat $format, array $headers ): array { $header_index = []; foreach ( $headers as $i => $header ) { $header_index[ trim( $header ) ] = $i; } if ( $format === CsvFormat::GOOGLE ) { return [ 'title' => $header_index['Subject'] ?? 0, 'start_date' => $header_index['Start Date'] ?? 1, 'start_time' => $header_index['Start Time'] ?? 2, 'end_date' => $header_index['End Date'] ?? 3, 'end_time' => $header_index['End Time'] ?? 4, 'all_day' => $header_index['All Day Event'] ?? 5, 'description' => $header_index['Description'] ?? 6, 'location' => $header_index['Location'] ?? 7, 'private' => $header_index['Private'] ?? 8, ]; } // Outlook format $map = [ 'title' => $header_index['Subject'] ?? 0, 'start_date' => $header_index['Start Date'] ?? 1, 'start_time' => $header_index['Start Time'] ?? 2, 'end_date' => $header_index['End Date'] ?? 3, 'end_time' => $header_index['End Time'] ?? 4, 'all_day' => $header_index['All day event'] ?? 5, ]; if ( isset( $header_index['Description'] ) ) { $map['description'] = $header_index['Description']; } if ( isset( $header_index['Location'] ) ) { $map['location'] = $header_index['Location']; } if ( isset( $header_index['Categories'] ) ) { $map['category'] = $header_index['Categories']; } if ( isset( $header_index['Sensitivity'] ) ) { $map['private'] = $header_index['Sensitivity']; } return $map; } /** * Convert a CSV row to an Event entity. * * @param array $row Column values. * @param array $map Column mapping. * @param string $login Target user login. * @param int $row_num Row number for warnings. * @return Event|null Null if the row should be skipped. */ private function row_to_event( array $row, array $map, string $login, int $row_num ): ?Event { $title = trim( $this->get_mapped_value( $row, $map, 'title' ) ); if ( $title === '' ) { $this->warnings[] = [ 'line' => $row_num, 'message' => __( 'Skipped: empty title.', 'agenticdaisy-calendar' ), ]; return null; } $start_date_str = $this->get_mapped_value( $row, $map, 'start_date' ); $start_time_str = $this->get_mapped_value( $row, $map, 'start_time' ); $end_date_str = $this->get_mapped_value( $row, $map, 'end_date' ); $end_time_str = $this->get_mapped_value( $row, $map, 'end_time' ); $all_day_str = strtolower( trim( $this->get_mapped_value( $row, $map, 'all_day' ) ) ); $description = $this->get_mapped_value( $row, $map, 'description' ); $location = $this->get_mapped_value( $row, $map, 'location' ); $private_str = $this->get_mapped_value( $row, $map, 'private' ); $all_day = in_array( $all_day_str, [ 'true', 'yes', '1' ], true ); $start_date = $this->parse_date( $start_date_str ); if ( $start_date === null ) { $this->warnings[] = [ 'line' => $row_num, 'message' => sprintf( // translators: %s is the date string that could not be parsed. __( 'Skipped: could not parse start date "%s".', 'agenticdaisy-calendar' ), $start_date_str ), ]; return null; } if ( ! $all_day && $start_time_str !== '' ) { $start_time = $this->parse_time( $start_time_str ); if ( $start_time !== null ) { $start_date = $start_date->setTime( (int) $start_time->format( 'H' ), (int) $start_time->format( 'i' ), (int) $start_time->format( 's' ) ); } } // Calculate duration from end date/time $end = null; if ( $end_date_str !== '' ) { $end = $this->parse_date( $end_date_str ); if ( $end !== null && ! $all_day && $end_time_str !== '' ) { $end_time = $this->parse_time( $end_time_str ); if ( $end_time !== null ) { $end = $end->setTime( (int) $end_time->format( 'H' ), (int) $end_time->format( 'i' ), (int) $end_time->format( 's' ) ); } } } $duration = $this->calculate_duration( $start_date, $end, $all_day ); $access = $this->map_access_level( $private_str ); $uid = $this->generate_uid( $row, $row_num ); return new Event( id: new EventId( 0 ), uid: $uid, name: $title, description: $description, location: $location, start: $start_date, duration: $duration, createdBy: $login, type: EventType::EVENT, access: $access, recurrence: new Recurrence(), allDay: $all_day, ); } /** * Assign category to an imported event if a category column is mapped. * * @param array $row Column values. * @param array $map Column mapping. * @param Event $event The imported event. * @param string $login Target user login. * @param User $user The user entity. * @param int $row_num Row number for warnings. */ private function assign_category_from_row( array $row, array $map, Event $event, string $login, User $user, int $row_num ): void { if ( ! isset( $map['category'] ) ) { return; } $cat_name = trim( $this->get_mapped_value( $row, $map, 'category' ) ); if ( $cat_name === '' ) { return; } try { $category_repo = $this->container->get_category_repository(); $category = $category_repo->findByName( $cat_name, $login ); if ( $category === null ) { $category = $category_repo->findByName( $cat_name, null ); } if ( $category !== null ) { // createEvent() leaves the entity at id 0; the category goes // on the stored row. $saved = $this->container->get_event_repository()->findByUidAndCreator( $event->uid(), $login ); if ( $saved !== null ) { $category_service = $this->container->get_category_service(); $category_service->assignToEvent( $saved->id(), $login, [ $category->id() ], $user ); } } } catch ( \Exception $e ) { $this->warnings[] = [ 'line' => $row_num, 'message' => sprintf( // translators: 1: category name, 2: error message. __( 'Could not assign category "%1$s": %2$s', 'agenticdaisy-calendar' ), $cat_name, $e->getMessage() ), ]; } } /** * Get a value from a CSV row by mapped field name. * * @param array $row Column values. * @param array $map Column mapping. * @param string $field Field name. */ private function get_mapped_value( array $row, array $map, string $field ): string { if ( ! isset( $map[ $field ] ) ) { return ''; } $index = $map[ $field ]; return isset( $row[ $index ] ) ? (string) $row[ $index ] : ''; } /** * Parse a date string trying multiple common formats. * * @param string $date_str The date string to parse. * @return \DateTimeImmutable|null Parsed date or null if unparseable. */ public function parse_date( string $date_str ): ?\DateTimeImmutable { $date_str = trim( $date_str ); if ( $date_str === '' ) { return null; } $formats = [ 'm/d/Y', // US: 01/15/2026 'n/j/Y', // US short: 1/5/2026 'd/m/Y', // UK/EU: 15/01/2026 'Y-m-d', // ISO: 2026-01-15 'm/d/Y g:i:s A', // US with time: 01/15/2026 2:30:00 PM 'm/d/Y H:i:s', // US with 24h time: 01/15/2026 14:30:00 'Y-m-d\TH:i:s', // ISO with time: 2026-01-15T14:30:00 'D n/j/Y', // Day prefix: Mon 1/5/2026 ]; foreach ( $formats as $format ) { $parsed = \DateTimeImmutable::createFromFormat( $format, $date_str ); if ( $parsed !== false ) { // For date-only formats, reset time to midnight if ( ! str_contains( $format, 'H' ) && ! str_contains( $format, 'g' ) ) { $parsed = $parsed->setTime( 0, 0, 0 ); } return $parsed; } } return null; } /** * Parse a time string trying multiple common formats. * * @param string $time_str The time string to parse. * @return \DateTimeImmutable|null Parsed time or null if unparseable. */ public function parse_time( string $time_str ): ?\DateTimeImmutable { $time_str = trim( $time_str ); if ( $time_str === '' ) { return null; } $formats = [ 'g:i:s A', // 12h with seconds: 2:30:00 PM 'g:i A', // 12h: 2:30 PM 'H:i:s', // 24h with seconds: 14:30:00 'H:i', // 24h: 14:30 ]; foreach ( $formats as $format ) { $parsed = \DateTimeImmutable::createFromFormat( $format, $time_str ); if ( $parsed !== false ) { return $parsed; } } return null; } /** * Calculate event duration in minutes. * * @param \DateTimeImmutable $start Start date/time. * @param \DateTimeImmutable|null $end End date/time. * @param bool $all_day Whether the event is all-day. * @return int Duration in minutes. */ private function calculate_duration( \DateTimeImmutable $start, ?\DateTimeImmutable $end, bool $all_day ): int { if ( $all_day ) { if ( $end !== null ) { $diff = $start->diff( $end ); $days = max( 1, $diff->days ); return $days * 1440; // 1440 minutes per day } return 1440; // Default: 1 day } if ( $end !== null ) { $diff_minutes = (int) ( ( $end->getTimestamp() - $start->getTimestamp() ) / 60 ); return max( 0, $diff_minutes ); } return 60; // Default: 1 hour } /** * Map privacy/sensitivity value to AccessLevel. * * @param string $value The privacy or sensitivity string. */ private function map_access_level( string $value ): AccessLevel { $lower = strtolower( trim( $value ) ); return match ( $lower ) { 'true', 'private' => AccessLevel::PRIVATE, 'confidential', 'personal' => AccessLevel::CONFIDENTIAL, default => AccessLevel::PUBLIC, }; } /** * Generate a unique UID for a CSV-imported event. * * @param array $row CSV row data. * @param int $index Row index. */ private function generate_uid( array $row, int $index ): string { $hash = md5( implode( '|', $row ) . '|' . $index ); $hostname = wp_parse_url( \home_url(), PHP_URL_HOST ); if ( ! is_string( $hostname ) || $hostname === '' ) { $hostname = 'localhost'; } return sprintf( 'webcal-csv-%s@%s', $hash, $hostname ); } }