$user_map Legacy login => WP login ('' = skip that user's data). * @param array{server_url?: string, include_categories?: bool, include_layers?: bool, include_remote_cals?: bool, include_site_extras?: bool, include_reminders?: bool} $options Import options. * @return array{import_id: int, imported: int, duplicates: int, skipped: int, categories: int, layers: int, remote: int, warnings: list} Report counters and warnings. */ public function import( LegacyDump $dump, DateTimeZone $site_tz, array $user_map, array $options = array() ): array { $server_url = (string) ( $options['server_url'] ?? ( $dump->metadata['host'] ?? '' ) ); $report = array( 'import_id' => 0, 'imported' => 0, 'duplicates' => 0, 'skipped' => 0, 'categories' => 0, 'layers' => 0, 'remote' => 0, 'warnings' => array(), ); $this->pdo->beginTransaction(); try { $import_id = $this->next_id( 'webcal_import', 'cal_import_id' ); $report['import_id'] = $import_id; $this->exec( "INSERT INTO {$this->prefix}webcal_import ( cal_import_id, cal_name, cal_date, cal_type, cal_login ) VALUES ( ?, ?, ?, ?, ? )", array( $import_id, 'WebCalendar 1.9 migration', (int) gmdate( 'Ymd' ), 'migration', '' ) ); $cat_map = ( $options['include_categories'] ?? true ) ? $this->import_categories( $dump, $user_map, $report ) : array(); list( $entry_map, $entry_owner_map ) = $this->import_entries( $dump, $site_tz, $user_map, $server_url, $import_id, $report ); $this->import_entry_children( $dump, $entry_map, $entry_owner_map, $cat_map, $user_map ); if ( $options['include_reminders'] ?? true ) { $this->copy_rekeyed( $dump, 'webcal_reminders', 'cal_id', $entry_map ); } if ( $options['include_site_extras'] ?? true ) { $this->copy_rekeyed( $dump, 'webcal_site_extras', 'cal_id', $entry_map ); } if ( $options['include_layers'] ?? true ) { $report['layers'] = $this->import_layers( $dump, $user_map ); } if ( $options['include_remote_cals'] ?? true ) { $report['remote'] = $this->import_nonuser_cals( $dump, $user_map ); } // A run that imported nothing (all duplicates/skips) would only // clutter the Previous Migrations list with an empty undo target. if ( 0 === $report['imported'] ) { $this->exec( "DELETE FROM {$this->prefix}webcal_import WHERE cal_import_id = ?", array( $import_id ) ); $report['import_id'] = 0; } $this->pdo->commit(); } catch ( \Throwable $e ) { $this->pdo->rollBack(); throw $e; } return $report; } /** * Past migration runs, newest first, with how many entries each inserted. * * @return list */ public function list_runs(): array { $stmt = $this->pdo->query( "SELECT i.cal_import_id, i.cal_date, COUNT(d.cal_id) AS entries FROM {$this->prefix}webcal_import i LEFT JOIN {$this->prefix}webcal_import_data d ON d.cal_import_id = i.cal_import_id WHERE i.cal_type = 'migration' GROUP BY i.cal_import_id, i.cal_date ORDER BY i.cal_import_id DESC" ); $runs = array(); if ( false !== $stmt ) { foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { $runs[] = array( 'import_id' => (int) $row['cal_import_id'], 'date' => (int) $row['cal_date'], 'entries' => (int) $row['entries'], ); } } return $runs; } /** * Deletes everything a previous migration run inserted. */ public function rollback( int $import_id ): int { // The run id comes from a form post. Another importer's run (a TEC // import) is not this undo's to remove. $type = $this->pdo->prepare( "SELECT cal_type FROM {$this->prefix}webcal_import WHERE cal_import_id = ?" ); $type->execute( array( $import_id ) ); if ( 'migration' !== $type->fetchColumn() ) { return 0; } // Only ids whose current entry is still the one this run created: a // lineage row can outlive its event (deletes did not use to remove // it), and core hands a freed id to the next new event, whoever's. $stmt = $this->pdo->prepare( "SELECT d.cal_id FROM {$this->prefix}webcal_import_data d JOIN {$this->prefix}webcal_entry e ON e.cal_id = d.cal_id AND e.cal_uid = d.cal_external_id AND e.cal_create_by = d.cal_login WHERE d.cal_import_id = ? AND d.cal_import_type = 'migration'" ); $stmt->execute( array( $import_id ) ); $ids = $stmt->fetchAll( PDO::FETCH_COLUMN ); $this->pdo->beginTransaction(); try { foreach ( $ids as $id ) { $tables = array_merge( \WebCalendar\WP\Database\EventSideData::TABLES, array( 'webcal_entry_repeats', 'webcal_entry_repeats_not', 'webcal_entry_user', 'webcal_entry' ) ); foreach ( $tables as $table ) { $this->exec( "DELETE FROM {$this->prefix}{$table} WHERE cal_id = ?", array( $id ) ); } } $this->exec( "DELETE FROM {$this->prefix}webcal_import_data WHERE cal_import_id = ?", array( $import_id ) ); $this->exec( "DELETE FROM {$this->prefix}webcal_import WHERE cal_import_id = ?", array( $import_id ) ); $this->pdo->commit(); } catch ( \Throwable $e ) { $this->pdo->rollBack(); throw $e; } return count( $ids ); } /** * @param array $user_map Login map. * @param array{import_id: int, imported: int, duplicates: int, skipped: int, categories: int, layers: int, remote: int, warnings: list} $report Accumulating report. * @return array Legacy cat_id => new cat_id. */ private function import_categories( LegacyDump $dump, array $user_map, array &$report ): array { $map = array(); foreach ( $dump->rows( 'webcal_categories' ) as $row ) { $legacy_id = (int) $row['cat_id']; $owner = $this->map_login( (string) ( $row['cat_owner'] ?? '' ), $user_map ); if ( null === $owner ) { continue; } $name = (string) ( $row['cat_name'] ?? '' ); $stmt = $this->pdo->prepare( "SELECT cat_id FROM {$this->prefix}webcal_categories WHERE cat_name = ? AND cat_owner = ?" ); $stmt->execute( array( $name, $owner ) ); $existing = $stmt->fetchColumn(); if ( false !== $existing ) { $map[ $legacy_id ] = (int) $existing; continue; } $new_id = $this->next_id( 'webcal_categories', 'cat_id' ); $this->exec( "INSERT INTO {$this->prefix}webcal_categories ( cat_id, cat_owner, cat_name, cat_color, cat_status ) VALUES ( ?, ?, ?, ?, ? )", array( $new_id, $owner, $name, $row['cat_color'] ?? null, $row['cat_status'] ?? 'A' ) ); $map[ $legacy_id ] = $new_id; $report['categories'] = (int) $report['categories'] + 1; } return $map; } /** * @param array $user_map Login map. * @param array{import_id: int, imported: int, duplicates: int, skipped: int, categories: int, layers: int, remote: int, warnings: list} $report Accumulating report. * @return array{0: array, 1: array} [ legacy cal_id => new cal_id, legacy cal_id => event-owner login ]. */ private function import_entries( LegacyDump $dump, DateTimeZone $site_tz, array $user_map, string $server_url, int $import_id, array &$report ): array { $deleted = $this->deleted_event_ids( $dump ); $map = array(); $owner_map = array(); foreach ( $dump->rows( 'webcal_entry' ) as $row ) { // Continuation rows from pre-1.9 databases are render duplicates. if ( null !== ( $row['cal_ext_for_id'] ?? null ) ) { ++$report['skipped']; continue; } // Legacy soft-deletes an event (every participant's status = 'D'); // such an event is gone from all calendars and must not be migrated. if ( isset( $deleted[ (int) $row['cal_id'] ] ) ) { ++$report['skipped']; continue; } $legacy_login = (string) ( $row['cal_create_by'] ?? '' ); $login = $this->map_login( $legacy_login, $user_map ); if ( null === $login || '' === $login ) { ++$report['skipped']; continue; } $legacy_id = (int) $row['cal_id']; $uid = LegacyTransformer::legacy_uid( $legacy_id, $server_url, $legacy_login ); $stmt = $this->pdo->prepare( "SELECT cal_id FROM {$this->prefix}webcal_entry WHERE cal_uid = ?" ); $stmt->execute( array( $uid ) ); if ( false !== $stmt->fetchColumn() ) { ++$report['duplicates']; continue; } $row = LegacyTransformer::transform_entry_times( $row, $site_tz ); $this->exec( "INSERT INTO {$this->prefix}webcal_entry ( cal_group_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, cal_uid, cal_sequence ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0 )", array( null, $login, (int) $row['cal_date'], (int) $row['cal_time'], $row['cal_mod_date'] ?? null, $row['cal_mod_time'] ?? null, (int) ( $row['cal_duration'] ?? 0 ), $row['cal_due_date'] ?? null, $row['cal_due_time'] ?? null, (int) ( $row['cal_priority'] ?? 5 ), $row['cal_type'] ?? 'E', $row['cal_access'] ?? 'P', (string) ( $row['cal_name'] ?? '' ), $row['cal_location'] ?? null, $row['cal_url'] ?? null, $row['cal_completed'] ?? null, $row['cal_description'] ?? null, $uid, ) ); $new_id = (int) $this->pdo->lastInsertId(); $map[ $legacy_id ] = $new_id; $owner_map[ $legacy_id ] = $login; ++$report['imported']; $this->exec( "INSERT INTO {$this->prefix}webcal_import_data ( cal_import_id, cal_id, cal_login, cal_import_type, cal_external_id ) VALUES ( ?, ?, ?, ?, ? )", array( $import_id, $new_id, $login, 'migration', $uid ) ); } return array( $map, $owner_map ); } /** * Legacy soft-deletes an event by setting every participant's * webcal_entry_user.cal_status to 'D'. Such an event is gone from all * calendars and must not be migrated. Returns the legacy cal_ids whose * participant rows are ALL 'D' (events with no participant rows are not * treated as deleted). * * @return array legacy cal_id => true */ private function deleted_event_ids( LegacyDump $dump ): array { $has_any = array(); $has_live = array(); foreach ( $dump->rows( 'webcal_entry_user' ) as $row ) { $id = (int) ( $row['cal_id'] ?? 0 ); $has_any[ $id ] = true; if ( 'D' !== (string) ( $row['cal_status'] ?? 'A' ) ) { $has_live[ $id ] = true; } } $deleted = array(); foreach ( array_keys( $has_any ) as $id ) { if ( ! isset( $has_live[ $id ] ) ) { $deleted[ $id ] = true; } } return $deleted; } /** * Repeats, exceptions, participants and category assignments. * * @param array $entry_map Legacy => new entry ids. * @param array $entry_owner_map Legacy cal_id => event-owner login. * @param array $cat_map Legacy => new category ids. * @param array $user_map Login map. */ private function import_entry_children( LegacyDump $dump, array $entry_map, array $entry_owner_map, array $cat_map, array $user_map ): void { foreach ( $dump->rows( 'webcal_entry_repeats' ) as $row ) { $new_id = $entry_map[ (int) $row['cal_id'] ] ?? null; if ( null === $new_id ) { continue; } $byday = (string) ( $row['cal_byday'] ?? '' ); if ( '' === $byday && '' !== (string) ( $row['cal_days'] ?? '' ) ) { $byday = LegacyTransformer::cal_days_to_byday( (string) $row['cal_days'] ); } // Drop junk tokens so a corrupt legacy value cannot build an // unparseable RRULE that later breaks the calendar feed. $byday = LegacyTransformer::sanitize_byday( $byday ); $bysetpos = LegacyTransformer::sanitize_bysetpos( (string) ( $row['cal_bysetpos'] ?? '' ) ); $this->exec( "INSERT INTO {$this->prefix}webcal_entry_repeats ( cal_id, cal_type, cal_end, cal_endtime, cal_frequency, cal_bymonth, cal_bymonthday, cal_byday, cal_bysetpos, cal_wkst, cal_count ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )", array( $new_id, LegacyTransformer::normalize_repeat_type( (string) ( $row['cal_type'] ?? 'daily' ) ), $row['cal_end'] ?? null, $row['cal_endtime'] ?? null, (int) ( $row['cal_frequency'] ?? 1 ), $row['cal_bymonth'] ?? null, $row['cal_bymonthday'] ?? null, '' !== $byday ? $byday : null, '' !== $bysetpos ? $bysetpos : null, $row['cal_wkst'] ?? 'MO', $row['cal_count'] ?? null, ) ); } foreach ( $dump->rows( 'webcal_entry_repeats_not' ) as $row ) { $new_id = $entry_map[ (int) $row['cal_id'] ] ?? null; if ( null === $new_id ) { continue; } // Exception dates are literal local dates: copied untouched. $this->exec( "INSERT INTO {$this->prefix}webcal_entry_repeats_not ( cal_id, cal_date, cal_exdate ) VALUES ( ?, ?, ? )", array( $new_id, (int) $row['cal_date'], (int) ( $row['cal_exdate'] ?? 1 ) ) ); } foreach ( $dump->rows( 'webcal_entry_user' ) as $row ) { $new_id = $entry_map[ (int) $row['cal_id'] ] ?? null; $login = $this->map_login( (string) ( $row['cal_login'] ?? '' ), $user_map ); if ( null === $new_id || null === $login || '' === $login ) { continue; } // A participant who deleted the event from their calendar (status // 'D') is not carried over. if ( 'D' === (string) ( $row['cal_status'] ?? 'A' ) ) { continue; } $category = null !== ( $row['cal_category'] ?? null ) ? ( $cat_map[ (int) $row['cal_category'] ] ?? null ) : null; $this->exec( "INSERT INTO {$this->prefix}webcal_entry_user ( cal_id, cal_login, cal_status, cal_category, cal_percent ) VALUES ( ?, ?, ?, ?, ? )", array( $new_id, $login, $row['cal_status'] ?? 'A', $category, (int) ( $row['cal_percent'] ?? 0 ) ) ); } $seen_categories = array(); foreach ( $dump->rows( 'webcal_entry_categories' ) as $row ) { $legacy_id = (int) $row['cal_id']; $new_id = $entry_map[ $legacy_id ] ?? null; $cat_id = $cat_map[ (int) $row['cat_id'] ] ?? null; if ( null === $new_id || null === $cat_id ) { continue; } // The plugin reads an event's categories by the viewing user // (webcal_entry_categories.cat_owner = ), so the assignment // must be owned by the event's creator. Legacy dumps store '' here, // which would never match. Prefer a real (mapped) legacy owner, then // fall back to the event owner. $owner = $this->map_login( (string) ( $row['cat_owner'] ?? '' ), $user_map ) ?? ''; if ( '' === $owner ) { $owner = $entry_owner_map[ $legacy_id ] ?? ''; } // Remapping owners can collapse a personal ('cknudsen') and a global // ('') legacy assignment of the same category onto one row; dedupe so // the (cal_id, cat_id, cat_owner) primary key is not violated. $key = $new_id . '|' . $cat_id . '|' . $owner; if ( isset( $seen_categories[ $key ] ) ) { continue; } $seen_categories[ $key ] = true; $this->exec( "INSERT INTO {$this->prefix}webcal_entry_categories ( cal_id, cat_id, cat_order, cat_owner ) VALUES ( ?, ?, ?, ? )", array( $new_id, $cat_id, (int) ( $row['cat_order'] ?? 1 ), $owner ) ); } } /** * @param array $entry_map Legacy => new entry ids. */ private function copy_rekeyed( LegacyDump $dump, string $table, string $id_column, array $entry_map ): void { foreach ( $dump->rows( $table ) as $row ) { $new_id = $entry_map[ (int) $row[ $id_column ] ] ?? null; if ( null === $new_id ) { continue; } $row[ $id_column ] = (string) $new_id; // Legacy dumps store NULL for columns the current schema declares // NOT NULL (e.g. webcal_reminders.cal_last_sent INT NOT NULL DEFAULT 0). // Drop NULL values so the column's schema default applies instead of // hitting an integrity-constraint violation. Non-null falsy values // (0, '', 'N') are kept. $row = array_filter( $row, static fn( $value ): bool => null !== $value ); if ( array() === $row ) { continue; } $columns = array_keys( $row ); $placeholders = implode( ', ', array_fill( 0, count( $columns ), '?' ) ); $this->exec( "INSERT INTO {$this->prefix}{$table} ( " . implode( ', ', $columns ) . " ) VALUES ( {$placeholders} )", array_values( $row ) ); } } /** * @param array $user_map Login map. */ private function import_layers( LegacyDump $dump, array $user_map ): int { $count = 0; foreach ( $dump->rows( 'webcal_user_layers' ) as $row ) { $login = $this->map_login( (string) ( $row['cal_login'] ?? '' ), $user_map ); $layer_user = $this->map_login( (string) ( $row['cal_layeruser'] ?? '' ), $user_map ); if ( null === $login || '' === $login || null === $layer_user || '' === $layer_user ) { continue; } $this->exec( "INSERT INTO {$this->prefix}webcal_user_layers ( cal_layerid, cal_login, cal_layeruser, cal_color, cal_dups ) VALUES ( ?, ?, ?, ?, ? )", array( $this->next_id( 'webcal_user_layers', 'cal_layerid' ), $login, $layer_user, $row['cal_color'] ?? null, $row['cal_dups'] ?? 'N' ) ); ++$count; } return $count; } /** * Nonuser calendars (rows with cal_url are remote subscriptions; the * caller should trigger a sync afterward rather than importing stale * webcal_import history). * * @param array $user_map Login map. */ private function import_nonuser_cals( LegacyDump $dump, array $user_map ): int { $count = 0; foreach ( $dump->rows( 'webcal_nonuser_cals' ) as $row ) { $admin = $this->map_login( (string) ( $row['cal_admin'] ?? '' ), $user_map ); if ( null === $admin || '' === $admin ) { continue; } $login = (string) ( $row['cal_login'] ?? '' ); $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM {$this->prefix}webcal_nonuser_cals WHERE cal_login = ?" ); $stmt->execute( array( $login ) ); if ( (int) $stmt->fetchColumn() > 0 ) { continue; } $this->exec( "INSERT INTO {$this->prefix}webcal_nonuser_cals ( cal_login, cal_lastname, cal_firstname, cal_admin, cal_is_public, cal_url ) VALUES ( ?, ?, ?, ?, ?, ? )", array( $login, $row['cal_lastname'], $row['cal_firstname'], $admin, $row['cal_is_public'] ?? 'N', $row['cal_url'] ) ); ++$count; } return $count; } /** * Maps a legacy login through the wizard's user mapping. The global '' * owner passes through; unmapped logins return null (= skip). * * @param array $user_map Login map. */ private function map_login( string $legacy_login, array $user_map ): ?string { if ( '' === $legacy_login ) { return ''; } $mapped = $user_map[ $legacy_login ] ?? null; return ( null === $mapped || '' === $mapped ) ? null : $mapped; } private function next_id( string $table, string $column ): int { $stmt = $this->pdo->query( "SELECT COALESCE(MAX({$column}), 0) + 1 FROM {$this->prefix}{$table}" ); return false === $stmt ? 1 : (int) $stmt->fetchColumn(); } /** * @param list $params Bound values. */ private function exec( string $sql, array $params ): void { $stmt = $this->pdo->prepare( $sql ); $stmt->execute( $params ); } }