is_available(); $allowed_tabs = [ 'ical', 'csv', 'legacy' ]; if ( $tec_available ) { $allowed_tabs[] = 'tec'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display tab, no state change $raw_tab = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'ical'; $active_tab = in_array( $raw_tab, $allowed_tabs, true ) ? $raw_tab : 'ical'; $page_url = \admin_url( 'admin.php?page=webcalendar-import' ); ?>

render_notice(); if ( $active_tab === 'csv' ) { $this->render_csv_tab(); } elseif ( $active_tab === 'legacy' ) { /** @var \wpdb $wpdb */ $wpdb = $GLOBALS['wpdb']; ( new LegacyMigrationTab( \WebCalendar\WP\Container::get_instance()->get_pdo(), $wpdb->prefix ) )->render(); } elseif ( $active_tab === 'tec' ) { $this->render_tec_tab(); } else { $this->render_ical_tab(); } ?>
render(); } /** * Render the iCal import tab. */ private function render_ical_tab(): void { $result = null; $error = ''; $ics_content = ''; $nonce = isset( $_POST['webcal_import_nonce'] ) && is_string( $_POST['webcal_import_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_import_nonce'] ) ) : ''; if ( wp_verify_nonce( $nonce, 'webcal_import' ) ) { [ $result, $error, $ics_content ] = $this->handle_import(); } if ( $error !== '' ) : ?>

render_result( $result, $ics_content ); ?>
get_import_targets(); if ( count( $import_targets ) > 1 ) : ?>

SUMMARY
DTSTART / DTEND
DESCRIPTION
LOCATION
RRULE
CLASS
CATEGORIES
ORGANIZER / ATTENDEE
UID

handle_csv_mapping(); } // Phase 1/2: handle CSV file upload $csv_nonce = isset( $_POST['webcal_csv_import_nonce'] ) && is_string( $_POST['webcal_csv_import_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_csv_import_nonce'] ) ) : ''; if ( $result === null && $error === '' && wp_verify_nonce( $csv_nonce, 'webcal_csv_import' ) ) { $upload_result = $this->handle_csv_upload(); if ( $upload_result['result'] !== null ) { $result = $upload_result['result']; } if ( $upload_result['error'] !== '' ) { $error = $upload_result['error']; } if ( $upload_result['show_mapping'] ) { $this->render_csv_mapping_phase( $upload_result ); return; } } if ( $error !== '' ) : ?>

render_result( $result ); ?> render_csv_upload_form(); ?>
get_import_targets(); if ( count( $import_targets ) > 1 ) : ?>

*
*

, preview: array>, transient_key: string, target_login: string} */ protected function handle_csv_upload(): array { $empty = [ 'result' => null, 'error' => '', 'show_mapping' => false, 'headers' => [], 'preview' => [], 'transient_key' => '', 'target_login' => '', ]; // Nonce verified by render_csv_tab() before calling this method. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in render_csv_tab() if ( ! isset( $_FILES['csv_file'] ) || ! is_array( $_FILES['csv_file'] ) ) { $empty['error'] = __( 'Please select a CSV file to upload.', 'agenticdaisy-calendar' ); return $empty; } [ $content, $error ] = $this->process_upload( 'csv_file', [ 'csv' => 'text/csv' ] ); if ( null === $content ) { $empty['error'] = $error; return $empty; } $importer = new CsvImporter( Container::get_instance() ); $rows = $importer->parse_csv( $content ); if ( count( $rows ) < 2 ) { $empty['error'] = __( 'CSV file is empty or has no data rows.', 'agenticdaisy-calendar' ); return $empty; } $headers = $rows[0]; $format = $importer->detect_format( $headers ); // Resolve target login (nonce verified in render_csv_tab()) $target_login = wp_get_current_user()->user_login; // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in render_csv_tab() $posted_target = isset( $_POST['csv_import_target'] ) && is_string( $_POST['csv_import_target'] ) ? sanitize_text_field( wp_unslash( $_POST['csv_import_target'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! empty( $posted_target ) ) { $valid_targets = array_map( fn( $t ) => $t['login'], $this->get_import_targets() ); if ( in_array( $posted_target, $valid_targets, true ) ) { $target_login = $posted_target; } } // Known format: import directly if ( $format !== CsvFormat::GENERIC ) { try { $result = $importer->import( $content, $target_login, $format ); return array_merge( $empty, [ 'result' => $result ] ); } catch ( \Exception $e ) { $logger = Container::get_instance()->get_logger(); $logger->error( 'CSV Import failed: ' . $e->getMessage() ); $empty['error'] = sprintf( // translators: %s is the error message. __( 'Error during CSV import: %s', 'agenticdaisy-calendar' ), esc_html( $e->getMessage() ) ); return $empty; } } // Generic format: store in transient and show column mapping UI $user_id = get_current_user_id(); $transient_key = 'webcal_csv_import_' . $user_id; set_transient( $transient_key, $content, 30 * MINUTE_IN_SECONDS ); $preview = array_slice( $rows, 1, 5 ); // First 5 data rows return [ 'result' => null, 'error' => '', 'show_mapping' => true, 'headers' => $headers, 'preview' => $preview, 'transient_key' => $transient_key, 'target_login' => $target_login, ]; } /** * Render the column mapping UI for generic CSV (Phase 2/3). * * @param array{headers: array, preview: array>, target_login: string} $data Upload result data. */ private function render_csv_mapping_phase( array $data ): void { $headers = $data['headers']; $preview = $data['preview']; $target_login = $data['target_login']; $field_labels = $this->get_field_labels(); ?>

0 ) : ?>

$_ ) : ?>
$col_index ) { if ( ! is_string( $col_index ) ) { continue; } $col_str = $col_index; if ( $col_str !== '' && in_array( $field, CsvImporter::MAPPABLE_FIELDS, true ) ) { $column_map[ $field ] = (int) $col_str; } } if ( ! isset( $column_map['title'] ) || ! isset( $column_map['start_date'] ) ) { return [ null, __( 'Title and Start Date columns are required.', 'agenticdaisy-calendar' ) ]; } // Resolve target login (nonce verified in render_csv_tab()) $target_login = wp_get_current_user()->user_login; // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in render_csv_tab() $posted_target = isset( $_POST['csv_target_login'] ) && is_string( $_POST['csv_target_login'] ) ? sanitize_text_field( wp_unslash( $_POST['csv_target_login'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! empty( $posted_target ) ) { $valid_targets = array_map( fn( $t ) => $t['login'], $this->get_import_targets() ); if ( in_array( $posted_target, $valid_targets, true ) ) { $target_login = $posted_target; } } delete_transient( $transient_key ); try { $importer = new CsvImporter( Container::get_instance() ); $result = $importer->import( $content, $target_login, CsvFormat::GENERIC, $column_map ); return [ $result, '' ]; } catch ( \Exception $e ) { $logger = Container::get_instance()->get_logger(); $logger->error( 'CSV Import (mapping) failed: ' . $e->getMessage() ); return [ null, sprintf( // translators: %s is the error message. __( 'Error during CSV import: %s', 'agenticdaisy-calendar' ), esc_html( $e->getMessage() ) ), ]; } } /** * Get human-readable labels for mappable CSV fields. * * @return array */ private function get_field_labels(): array { return [ 'title' => __( 'Title', 'agenticdaisy-calendar' ), 'start_date' => __( 'Start Date', 'agenticdaisy-calendar' ), 'start_time' => __( 'Start Time', 'agenticdaisy-calendar' ), 'end_date' => __( 'End Date', 'agenticdaisy-calendar' ), 'end_time' => __( 'End Time', 'agenticdaisy-calendar' ), 'all_day' => __( 'All Day', 'agenticdaisy-calendar' ), 'description' => __( 'Description', 'agenticdaisy-calendar' ), 'location' => __( 'Location', 'agenticdaisy-calendar' ), 'category' => __( 'Category', 'agenticdaisy-calendar' ), 'private' => __( 'Private / Sensitivity', 'agenticdaisy-calendar' ), ]; } /** * Guess if a header column matches a field (for auto-select in mapping UI). * * @param string $field The field name. * @param string $header The CSV header column name. */ private function guess_column_for_field( string $field, string $header ): bool { $lower = strtolower( trim( $header ) ); return match ( $field ) { 'title' => in_array( $lower, [ 'subject', 'title', 'name', 'event', 'event name' ], true ), 'start_date' => in_array( $lower, [ 'start date', 'start', 'date', 'begin date' ], true ), 'start_time' => in_array( $lower, [ 'start time', 'time', 'begin time' ], true ), 'end_date' => in_array( $lower, [ 'end date', 'end' ], true ), 'end_time' => in_array( $lower, [ 'end time' ], true ), 'all_day' => in_array( $lower, [ 'all day event', 'all day', 'all-day', 'allday' ], true ), 'description' => in_array( $lower, [ 'description', 'details', 'notes', 'body' ], true ), 'location' => in_array( $lower, [ 'location', 'place', 'venue' ], true ), 'category' => in_array( $lower, [ 'category', 'categories', 'type' ], true ), 'private' => in_array( $lower, [ 'private', 'sensitivity', 'visibility' ], true ), default => false, }; } /** * Render the import result notice with optional expandable warnings. * * @param string $source_content Optional source content for line number resolution (iCal only). */ private function render_result( ImportResult $result, string $source_content = '' ): void { $message = sprintf( // translators: %d is the number of events imported. __( 'Import completed successfully. %d event(s) imported.', 'agenticdaisy-calendar' ), $result->importedCount ); if ( $result->updatedCount > 0 ) { $message .= ' ' . sprintf( // translators: %d is the number of existing events updated. __( '%d existing event(s) updated.', 'agenticdaisy-calendar' ), $result->updatedCount ); } if ( $result->skippedCount > 0 ) { $message .= ' ' . sprintf( // translators: %d is the number of events skipped. __( '%d event(s) skipped.', 'agenticdaisy-calendar' ), $result->skippedCount ); // Skipped events are silent by nature: the import "succeeds" and // nothing changes. Say why, and how to override it, so a repeat // import of a corrected file is not mistaken for a no-op bug. if ( 0 === $result->updatedCount ) { $message .= ' ' . __( 'Skipped events were already imported (matched by UID). To overwrite them with the contents of this file, import again with "Update events that have already been imported" checked.', 'agenticdaisy-calendar' ); } } // Resolve line numbers from UID positions in ICS content $warnings = $result->warnings; if ( count( $warnings ) > 0 && $source_content !== '' ) { $uid_map = $this->build_uid_line_map( $source_content ); $warnings = $this->resolve_warning_lines( $warnings, $uid_map ); } ?>

0 ) : ?>
0 ? esc_html( (string) $warning['line'] ) : '—'; ?>
UID → line number. */ private function build_uid_line_map( string $content ): array { $lines = explode( "\n", str_replace( "\r\n", "\n", $content ) ); $map = []; $vevent_line = 0; foreach ( $lines as $index => $line ) { $trimmed = rtrim( $line ); if ( $trimmed === 'BEGIN:VEVENT' ) { $vevent_line = $index + 1; // 1-based } elseif ( str_starts_with( $trimmed, 'UID:' ) ) { $uid = substr( $trimmed, 4 ); $map[ $uid ] = $vevent_line; } } return $map; } /** * Resolve line numbers in import warnings by matching UIDs to ICS content. * * @param array $warnings Warnings from ImportResult. * @param array $uid_map UID → line number map. * @return array Warnings with resolved line numbers. */ private function resolve_warning_lines( array $warnings, array $uid_map ): array { foreach ( $warnings as &$warning ) { if ( $warning['line'] !== 0 ) { continue; } // Core format: "Failed to import event {UID}: {error}" if ( preg_match( '/^Failed to import event (.+?): /', $warning['message'], $matches ) ) { $uid = $matches[1]; if ( isset( $uid_map[ $uid ] ) ) { $warning['line'] = $uid_map[ $uid ]; } } } return $warnings; } /** * Get available import targets for the current user. * * @return array */ private function get_import_targets(): array { $targets = []; $current_login = wp_get_current_user()->user_login; // Always include own calendar first $targets[] = [ 'login' => $current_login, 'name' => __( 'My Calendar', 'agenticdaisy-calendar' ), ]; // Add shared calendars the user can manage $container = Container::get_instance(); $resource_repo = $container->get_resource_repository(); if ( current_user_can( 'webcal_can_manage_shared_calendars' ) ) { $resources = $resource_repo->findAll(); } else { $resources = $resource_repo->findByAdmin( $current_login ); } foreach ( $resources as $resource ) { if ( $resource->url() !== null ) { continue; // Skip remote calendars } $targets[] = [ 'login' => $resource->login(), 'name' => $resource->name(), ]; } return $targets; } /** * Handle the iCal file upload and import. * * @return array{0: ?ImportResult, 1: string, 2: string} Result, error message, and ICS content. */ protected function handle_import(): array { // Nonce verified by render_ical_tab() before calling this method. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in render_ical_tab() if ( ! isset( $_FILES['ical_file'] ) || ! is_array( $_FILES['ical_file'] ) ) { return [ null, __( 'Please select a file to upload.', 'agenticdaisy-calendar' ), '' ]; } [ $content, $error ] = $this->process_upload( 'ical_file', [ 'ics' => 'text/calendar', 'ical' => 'text/calendar', 'ifb' => 'text/calendar', ] ); if ( null === $content ) { return [ null, $error, '' ]; } // Content-level guard: a .ics file with a valid text/calendar MIME // could still contain non-iCalendar bytes. Reject early. if ( strpos( ltrim( $content ), 'BEGIN:VCALENDAR' ) !== 0 ) { return [ null, __( 'Invalid file content. The file does not appear to be a valid iCalendar file.', 'agenticdaisy-calendar' ), '' ]; } try { $container = Container::get_instance(); $import_svc = $container->get_import_service(); $user_repo = $container->get_user_repository(); // Resolve import target (nonce verified in render_ical_tab()) $target_login = wp_get_current_user()->user_login; // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in render_ical_tab() $posted_target = isset( $_POST['import_target'] ) && is_string( $_POST['import_target'] ) ? sanitize_text_field( wp_unslash( $_POST['import_target'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! empty( $posted_target ) ) { $candidate = $posted_target; // Verify user is allowed to import to this target $valid_targets = array_map( fn( $t ) => $t['login'], $this->get_import_targets() ); if ( in_array( $candidate, $valid_targets, true ) ) { $target_login = $candidate; } } $user = $user_repo->findByLogin( $target_login ); if ( ! $user ) { // Target is a shared calendar (nonuser) — create a pseudo User $user = new \WebCalendar\Core\Domain\Entity\User( login: $target_login, firstName: $target_login, lastName: '', email: $target_login . '@system.local', isAdmin: false, isEnabled: true, ); } // Opt-in: overwrite events whose UID we have already imported. // Off by default so a repeat import stays the safe no-op it has // always been and local edits are never clobbered by surprise. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in render_ical_tab() $update_existing = isset( $_POST['update_existing'] ) && '1' === $_POST['update_existing']; $result = ImportUidCollisions::fold( $import_svc->importIcal( $content, $user, $update_existing ) ); // Pro: sideload RFC 7986 IMAGE URLs into the WordPress Media Library. // Gated as a Starter+ feature; free tier keeps the hotlinked URL fallback. // The ImageSideloader class is stripped from the auto-generated free // build via @fs_premium_only, so class_exists() is false there. if ( class_exists( \WebCalendar\WP\Admin\ImageSideloader::class ) && \webcal_feature_available( 'image_sideload' ) ) { ( new \WebCalendar\WP\Admin\ImageSideloader() )->sideload_event_images( $target_login ); } return [ $result, '', $content ]; } catch ( \WebCalendarDeps\Icalendar\Exception\ParseException $e ) { $logger = Container::get_instance()->get_logger(); $logger->error( sprintf( 'iCal Import failed: %s at line %d. Content: %s', $e->getMessage(), $e->getContentLineNumber(), $e->getContentLine() ?? '' ) ); $msg = sprintf( // translators: 1: error message, 2: line number, 3: line content. __( 'Error during import: %1$s at line %2$d. Problematic line: %3$s', 'agenticdaisy-calendar' ), esc_html( $e->getMessage() ), (int) $e->getContentLineNumber(), esc_html( substr( $e->getContentLine() ?? '', 0, 100 ) ) ); return [ null, $msg, '' ]; } catch ( \Exception $e ) { $logger = Container::get_instance()->get_logger(); $logger->error( 'iCal Import failed: ' . $e->getMessage() ); // translators: %s is the error message. return [ null, sprintf( __( 'Error during import: %s', 'agenticdaisy-calendar' ), esc_html( $e->getMessage() ) ), '' ]; } } /** * Map a PHP upload error code to a human-readable message. * * @param int $code One of the UPLOAD_ERR_* constants. */ private function get_upload_error_message( int $code ): string { return UploadError::for_code( $code, UploadError::ini_size( 'upload_max_filesize' ), UploadError::ini_size( 'post_max_size' ) ); } /** * Stage an uploaded file via `wp_handle_upload()` and return its contents. * * Shared pipeline for CSV (WPORG-9.4) and iCal (WPORG-9.5) uploads — * extracted in WPORG-9.6 to DRY the boot → upload → read sequence. * Assumes the caller has already verified that `$_FILES[$field_name]` * is a set, array-shaped entry (each caller emits its own * "please-select-a-file" message before reaching the helper). * * @param string $field_name The `$_FILES` key. * @param array $mimes The 'mimes' allowlist passed to * `wp_handle_upload()`. * * @return array{0: ?string, 1: string} On success, `[string $content, '']`. * On failure, `[null, $error_message]`. */ protected function process_upload( string $field_name, array $mimes ): array { // Defence-in-depth guard: callers check this too, but narrowing // here keeps PHPStan Level 9 happy and gives a sane fallback if a // future caller forgets. Psalm's WP plugin knows $_FILES is always // an array shape so it flags the second clause as TypeDoesNotContainType; // PHPStan doesn't have that stub and needs it. /** @psalm-suppress TypeDoesNotContainType -- defensive guard for PHPStan Level 9 */ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by caller if ( ! isset( $_FILES[ $field_name ] ) || ! is_array( $_FILES[ $field_name ] ) ) { return [ null, __( 'Please select a file to upload.', 'agenticdaisy-calendar' ) ]; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified by caller; wp_handle_upload() below performs the sanitization $raw_file_field = $_FILES[ $field_name ]; // PHP 8.1+ adds a `full_path` key to $_FILES that wp_handle_upload's // strict-shape declaration rejects. Reduce to the canonical 5 keys // that wp_handle_upload actually consumes, and coerce each one // safely. The is_scalar/is_numeric guards and casts are required // by PHPStan Level 9 even though Psalm's WP stub already knows the // shape (hence the suppressions). /** * @psalm-suppress RedundantCast -- defensive coercion for PHPStan Level 9 * @psalm-suppress RedundantCastGivenDocblockType -- same as above */ $file_field = [ 'name' => is_scalar( $raw_file_field['name'] ?? null ) ? (string) $raw_file_field['name'] : '', 'type' => is_scalar( $raw_file_field['type'] ?? null ) ? (string) $raw_file_field['type'] : '', 'tmp_name' => is_scalar( $raw_file_field['tmp_name'] ?? null ) ? (string) $raw_file_field['tmp_name'] : '', 'error' => is_numeric( $raw_file_field['error'] ?? null ) ? (int) $raw_file_field['error'] : UPLOAD_ERR_NO_FILE, 'size' => is_numeric( $raw_file_field['size'] ?? null ) ? (int) $raw_file_field['size'] : 0, ]; $upload_error = $file_field['error']; if ( 0 !== $upload_error ) { return [ null, $this->get_upload_error_message( $upload_error ) ]; } if ( ! function_exists( 'wp_handle_upload' ) ) { /** @psalm-suppress MissingFile -- WP-core file resolved via ABSPATH at runtime. */ require_once ABSPATH . 'wp-admin/includes/file.php'; } $upload = wp_handle_upload( $file_field, [ 'test_form' => false, 'mimes' => $mimes, ] ); if ( isset( $upload['error'] ) ) { return [ null, (string) $upload['error'] ]; } $uploaded_path = is_string( $upload['file'] ?? null ) ? $upload['file'] : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a WP-validated upload staged by wp_handle_upload() above $content = $uploaded_path !== '' ? file_get_contents( $uploaded_path ) : false; // WPORG-9.7: clean up the WP-staged temp upload in all paths // that produced a staged file. wp_delete_file is the WP-sanctioned // deletion helper; it gates the unlink through the // `wp_delete_file` filter so other plugins can audit or veto. if ( $uploaded_path !== '' ) { wp_delete_file( $uploaded_path ); } if ( false === $content ) { return [ null, __( 'Failed to read the uploaded file.', 'agenticdaisy-calendar' ) ]; } return [ $content, '' ]; } }