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 ); ?>
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(); ?>
, 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();
?>
$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, '' ];
}
}