> */ private const SCHEMA_COLUMNS = array( 'webcal_user' => array( 'cal_login', 'cal_passwd', 'cal_lastname', 'cal_firstname', 'cal_is_admin', 'cal_email', 'cal_enabled', 'cal_telephone', 'cal_address', 'cal_title', 'cal_birthday', 'cal_last_login', 'cal_api_token' ), 'webcal_entry' => array( 'cal_id', 'cal_group_id', 'cal_ext_for_id', 'cal_create_by', 'cal_date', 'cal_time', 'cal_mod_date', 'cal_mod_time', 'cal_duration', 'cal_due_date', 'cal_due_time', 'cal_priority', 'cal_type', 'cal_access', 'cal_name', 'cal_location', 'cal_url', 'cal_completed', 'cal_description' ), 'webcal_entry_repeats' => array( 'cal_id', 'cal_type', 'cal_end', 'cal_endtime', 'cal_frequency', 'cal_days', 'cal_bymonth', 'cal_bymonthday', 'cal_byday', 'cal_bysetpos', 'cal_byweekno', 'cal_byyearday', 'cal_wkst', 'cal_count' ), 'webcal_entry_repeats_not' => array( 'cal_id', 'cal_date', 'cal_exdate' ), 'webcal_entry_user' => array( 'cal_id', 'cal_login', 'cal_status', 'cal_category', 'cal_percent' ), 'webcal_entry_categories' => array( 'cal_id', 'cat_id', 'cat_order', 'cat_owner' ), 'webcal_categories' => array( 'cat_id', 'cat_owner', 'cat_name', 'cat_color', 'cat_status', 'cat_icon_mime', 'cat_icon_blob' ), 'webcal_user_layers' => array( 'cal_layerid', 'cal_login', 'cal_layeruser', 'cal_color', 'cal_dups' ), 'webcal_nonuser_cals' => array( 'cal_login', 'cal_lastname', 'cal_firstname', 'cal_admin', 'cal_is_public', 'cal_url' ), 'webcal_site_extras' => array( 'cal_id', 'cal_name', 'cal_type', 'cal_date', 'cal_remind', 'cal_data' ), 'webcal_user_pref' => array( 'cal_login', 'cal_setting', 'cal_value' ), 'webcal_reminders' => array( 'cal_id', 'cal_date', 'cal_offset', 'cal_related', 'cal_before', 'cal_last_sent', 'cal_repeats', 'cal_duration', 'cal_times_sent', 'cal_action' ), 'webcal_config' => array( 'cal_setting', 'cal_value' ), 'webcal_entry_log' => array( 'cal_log_id', 'cal_entry_id', 'cal_login', 'cal_user_cal', 'cal_type', 'cal_date', 'cal_time', 'cal_text' ), ); /** * Columns actually consumed by the migration. Anything else — notably * cal_passwd, cal_api_token and binary category icons — is dropped here. * * @var array> */ private const KEPT_COLUMNS = array( 'webcal_user' => array( 'cal_login', 'cal_lastname', 'cal_firstname', 'cal_is_admin', 'cal_email', 'cal_enabled' ), 'webcal_entry' => array( 'cal_id', 'cal_group_id', 'cal_ext_for_id', 'cal_create_by', 'cal_date', 'cal_time', 'cal_mod_date', 'cal_mod_time', 'cal_duration', 'cal_due_date', 'cal_due_time', 'cal_priority', 'cal_type', 'cal_access', 'cal_name', 'cal_location', 'cal_url', 'cal_completed', 'cal_description' ), 'webcal_entry_repeats' => array( 'cal_id', 'cal_type', 'cal_end', 'cal_endtime', 'cal_frequency', 'cal_days', 'cal_bymonth', 'cal_bymonthday', 'cal_byday', 'cal_bysetpos', 'cal_byweekno', 'cal_byyearday', 'cal_wkst', 'cal_count' ), 'webcal_entry_repeats_not' => array( 'cal_id', 'cal_date', 'cal_exdate' ), 'webcal_entry_user' => array( 'cal_id', 'cal_login', 'cal_status', 'cal_category', 'cal_percent' ), 'webcal_entry_categories' => array( 'cal_id', 'cat_id', 'cat_order', 'cat_owner' ), 'webcal_categories' => array( 'cat_id', 'cat_owner', 'cat_name', 'cat_color', 'cat_status' ), 'webcal_user_layers' => array( 'cal_layerid', 'cal_login', 'cal_layeruser', 'cal_color', 'cal_dups' ), 'webcal_nonuser_cals' => array( 'cal_login', 'cal_lastname', 'cal_firstname', 'cal_admin', 'cal_is_public', 'cal_url' ), 'webcal_site_extras' => array( 'cal_id', 'cal_name', 'cal_type', 'cal_date', 'cal_remind', 'cal_data' ), 'webcal_user_pref' => array( 'cal_login', 'cal_setting', 'cal_value' ), 'webcal_reminders' => array( 'cal_id', 'cal_date', 'cal_offset', 'cal_related', 'cal_before', 'cal_last_sent', 'cal_repeats', 'cal_duration', 'cal_times_sent', 'cal_action' ), 'webcal_config' => array( 'cal_setting', 'cal_value' ), 'webcal_entry_log' => array( 'cal_log_id', 'cal_entry_id', 'cal_login', 'cal_user_cal', 'cal_type', 'cal_date', 'cal_time', 'cal_text' ), ); /** * Dump charsets that need transcoding to UTF-8, mapped to iconv names. * * @var array */ private const TRANSCODE = array( 'latin1' => 'ISO-8859-1', 'latin2' => 'ISO-8859-2', ); public function parse_file( string $path ): LegacyDump { $sql = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file, not remote. if ( false === $sql ) { return new LegacyDump( array(), '', array(), array( 'Could not read file.' ), array() ); } return $this->parse( $sql ); } public function parse( string $sql ): LegacyDump { $metadata = array(); $charset = ''; $tables = array(); $warnings = array(); $skipped = array(); $length = strlen( $sql ); $pos = 0; while ( $pos < $length ) { // Skip whitespace between statements. while ( $pos < $length && ctype_space( $sql[ $pos ] ) ) { ++$pos; } if ( $pos >= $length ) { break; } // Line comment: may carry our metadata header. if ( '-' === $sql[ $pos ] && '-' === ( $sql[ $pos + 1 ] ?? '' ) ) { $eol = strpos( $sql, "\n", $pos ); $line = substr( $sql, $pos, ( false === $eol ? $length : $eol ) - $pos ); if ( str_starts_with( $line, '-- webcalendar-export:' ) ) { $metadata = $this->parse_metadata( $line ); if ( isset( $metadata['charset'] ) && '' === $charset ) { $charset = strtolower( $metadata['charset'] ); } } $pos = ( false === $eol ) ? $length : $eol + 1; continue; } // Block comment (incl. mysqldump /*! ... */ conditionals). if ( '/' === $sql[ $pos ] && '*' === ( $sql[ $pos + 1 ] ?? '' ) ) { $end = strpos( $sql, '*/', $pos + 2 ); $comment = substr( $sql, $pos, ( false === $end ? $length : $end + 2 ) - $pos ); if ( preg_match( '/SET\s+NAMES\s+[\'"`]?([A-Za-z0-9_]+)/i', $comment, $m ) ) { $charset = strtolower( $m[1] ); } $pos = ( false === $end ) ? $length : $end + 2; // A conditional comment may be its own statement; swallow a trailing semicolon. while ( $pos < $length && ( ctype_space( $sql[ $pos ] ) || ';' === $sql[ $pos ] ) ) { ++$pos; } continue; } $statement = $this->read_statement( $sql, $pos ); if ( preg_match( '/^INSERT\s+INTO\s+`?([A-Za-z0-9_]+)`?\s*(?:\(([^)]*)\))?\s*VALUES\s*/is', $statement, $m, PREG_OFFSET_CAPTURE ) ) { $table = strtolower( $m[1][0] ); if ( ! isset( self::SCHEMA_COLUMNS[ $table ] ) ) { $skipped[ $table ] = ( $skipped[ $table ] ?? 0 ) + 1; continue; } $columns = isset( $m[2] ) && '' !== trim( $m[2][0] ) ? array_map( fn( string $c ): string => strtolower( trim( $c, " \t\n\r`" ) ), explode( ',', $m[2][0] ) ) : self::SCHEMA_COLUMNS[ $table ]; $values_sql = substr( $statement, strlen( $m[0][0] ) ); $this->parse_tuples( $table, $columns, $values_sql, $tables, $warnings ); continue; } if ( preg_match( '/^SET\s+NAMES\s+[\'"`]?([A-Za-z0-9_]+)/i', $statement, $m ) ) { $charset = strtolower( $m[1] ); } // Everything else (LOCK/UNLOCK/CREATE/DROP/ALTER/SET ...) is ignored. } if ( isset( self::TRANSCODE[ $charset ] ) ) { $tables = $this->transcode_tables( $tables, self::TRANSCODE[ $charset ] ); } elseif ( '' !== $charset && ! in_array( $charset, array( 'utf8', 'utf8mb3', 'utf8mb4' ), true ) ) { $warnings[] = sprintf( 'Unrecognized dump charset "%s"; values were not transcoded.', $charset ); } return new LegacyDump( $metadata, $charset, $tables, $warnings, $skipped ); } /** * Reads one statement (up to an unquoted semicolon), advancing $pos past it. */ private function read_statement( string $sql, int &$pos ): string { $length = strlen( $sql ); $start = $pos; $quote = ''; while ( $pos < $length ) { $ch = $sql[ $pos ]; if ( '' !== $quote ) { if ( '\\' === $ch && '`' !== $quote ) { $pos += 2; continue; } if ( $ch === $quote ) { $quote = ''; } ++$pos; continue; } if ( "'" === $ch || '"' === $ch || '`' === $ch ) { $quote = $ch; ++$pos; continue; } if ( ';' === $ch ) { ++$pos; return substr( $sql, $start, $pos - $start - 1 ); } ++$pos; } return substr( $sql, $start ); } /** * Parses the VALUES (...),(...) portion of an INSERT. * * @param array>> $tables Accumulator. * @param list $warnings Accumulator. * @param list $columns Column names for positional mapping. */ private function parse_tuples( string $table, array $columns, string $values_sql, array &$tables, array &$warnings ): void { $kept = self::KEPT_COLUMNS[ $table ]; $length = strlen( $values_sql ); $pos = 0; $col_cnt = count( $columns ); while ( $pos < $length ) { while ( $pos < $length && ( ctype_space( $values_sql[ $pos ] ) || ',' === $values_sql[ $pos ] ) ) { ++$pos; } if ( $pos >= $length ) { break; } if ( '(' !== $values_sql[ $pos ] ) { $warnings[] = sprintf( 'Malformed VALUES in %s near offset %d; rest of statement skipped.', $table, $pos ); return; } ++$pos; $values = array(); while ( $pos < $length ) { while ( $pos < $length && ctype_space( $values_sql[ $pos ] ) ) { ++$pos; } $ch = $values_sql[ $pos ] ?? ''; if ( "'" === $ch || '"' === $ch ) { $values[] = $this->read_quoted( $values_sql, $pos, $ch ); } elseif ( 0 === strncasecmp( substr( $values_sql, $pos, 4 ), 'NULL', 4 ) ) { $values[] = null; $pos += 4; } else { $end = $pos; while ( $end < $length && ',' !== $values_sql[ $end ] && ')' !== $values_sql[ $end ] ) { ++$end; } $values[] = trim( substr( $values_sql, $pos, $end - $pos ) ); $pos = $end; } while ( $pos < $length && ctype_space( $values_sql[ $pos ] ) ) { ++$pos; } if ( ',' === ( $values_sql[ $pos ] ?? '' ) ) { ++$pos; continue; } if ( ')' === ( $values_sql[ $pos ] ?? '' ) ) { ++$pos; break; } $warnings[] = sprintf( 'Unterminated tuple in %s; rest of statement skipped.', $table ); return; } if ( count( $values ) !== $col_cnt ) { $warnings[] = sprintf( 'Row in %s has %d values for %d columns; row skipped.', $table, count( $values ), $col_cnt ); continue; } $row = array_combine( $columns, $values ); $out = array(); foreach ( $kept as $col ) { if ( array_key_exists( $col, $row ) ) { $out[ $col ] = $row[ $col ]; } } $tables[ $table ][] = $out; } } /** * Reads a quoted string literal, decoding MySQL escapes, advancing $pos past it. */ private function read_quoted( string $sql, int &$pos, string $quote ): string { $length = strlen( $sql ); $out = ''; ++$pos; // Opening quote. while ( $pos < $length ) { $ch = $sql[ $pos ]; if ( '\\' === $ch ) { $next = $sql[ $pos + 1 ] ?? ''; $out .= match ( $next ) { '0' => "\0", 'n' => "\n", 'r' => "\r", 't' => "\t", 'Z' => "\x1a", 'b' => "\x08", default => $next, // Includes \\ \' \" and MySQL's "drop the backslash" rule. }; $pos += 2; continue; } if ( $ch === $quote ) { // Doubled quote is an escaped quote. if ( ( $sql[ $pos + 1 ] ?? '' ) === $quote ) { $out .= $quote; $pos += 2; continue; } ++$pos; return $out; } $out .= $ch; ++$pos; } return $out; } /** * @return array */ private function parse_metadata( string $line ): array { $metadata = array(); $payload = trim( substr( $line, strlen( '-- webcalendar-export:' ) ) ); foreach ( preg_split( '/\s+/', $payload ) ?: array() as $pair ) { $eq = strpos( $pair, '=' ); if ( false !== $eq ) { $metadata[ substr( $pair, 0, $eq ) ] = substr( $pair, $eq + 1 ); } } return $metadata; } /** * @param array>> $tables Parsed rows. * @return array>> */ private function transcode_tables( array $tables, string $from ): array { $out = array(); foreach ( $tables as $table => $rows ) { $new_rows = array(); foreach ( $rows as $row ) { foreach ( $row as $col => $value ) { if ( is_string( $value ) && '' !== $value ) { $converted = iconv( $from, 'UTF-8//TRANSLIT', $value ); if ( false !== $converted ) { $row[ $col ] = $converted; } } } $new_rows[] = $row; } $out[ $table ] = $new_rows; } return $out; } }