scheduler ??= new TecSyncScheduler( $this->reader, $this ); return $this->scheduler; } /** * Production wiring, shared by the Import screen and the CLI. */ public static function create(): self { /** @var \wpdb $wpdb */ $wpdb = $GLOBALS['wpdb']; $container = \WebCalendar\WP\Container::get_instance(); $host = wp_parse_url( home_url(), PHP_URL_HOST ); return new self( new \WebCalendar\WP\Migration\Tec\WpdbTecReader(), new TecImporter( $container->get_pdo(), $wpdb->prefix, $container->get_event_repository() ), new TecEventMapper( wp_timezone(), is_string( $host ) && '' !== $host ? $host : 'WordPress.local', new \WebCalendar\WP\Migration\Tec\TecRecurrenceConverter() ), $container->get_resource_repository() ); } /** * Shared calendars that can own imported events: resources without a * URL (a URL means a remote-calendar subscription), login => name. * * @return array */ public function shared_calendars(): array { if ( null === $this->resources ) { return array(); } try { $calendars = array(); foreach ( $this->resources->findAll() as $resource ) { if ( null === $resource->url() ) { $calendars[ $resource->login() ] = $resource->name(); } } return $calendars; } catch ( \Exception $e ) { return array(); } } public function render(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- step routing only; each handler verifies its own nonce. $step = isset( $_POST['webcal_tec_step'] ) && is_string( $_POST['webcal_tec_step'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_tec_step'] ) ) : ''; if ( 'run' === $step ) { $this->handle_run(); return; } if ( 'rollback' === $step ) { $this->handle_rollback(); return; } if ( 'sync-settings' === $step ) { $this->handle_sync_settings(); return; } if ( 'remove-all' === $step ) { $this->handle_remove_all(); return; } if ( 'sync-now' === $step ) { $this->handle_sync_now(); return; } $this->render_analysis(); } /** * What the site's TEC data looks like, for the analysis screen. * * @return array{available: bool, status_counts: array, recurring: int, with_venue: int, with_organizer: int, categories: int} */ public function analyze(): array { if ( ! $this->reader->is_available() ) { return array( 'available' => false, 'status_counts' => array(), 'recurring' => 0, 'with_venue' => 0, 'with_organizer' => 0, 'categories' => 0, ); } $recurring = 0; $with_venue = 0; $with_organizer = 0; foreach ( $this->reader->events() as $event ) { if ( null !== $event->recurrence ) { ++$recurring; } if ( null !== $event->venue_id ) { ++$with_venue; } if ( null !== $event->organizer_id ) { ++$with_organizer; } } return array( 'available' => true, 'status_counts' => $this->reader->count_events_by_status(), 'recurring' => $recurring, 'with_venue' => $with_venue, 'with_organizer' => $with_organizer, 'categories' => count( $this->reader->categories() ), ); } /** * A warning naming the Additional Fields this import does not carry. * * Recorded on the run as well as shown beforehand, so the history of an * import says what it left behind rather than only what it took. * * @return list */ private function additional_field_warning(): array { $unreadable = array_column( array_values( array_filter( $this->reader->additional_fields(), static fn( array $field ): bool => '' === $field['key'] ) ), 'label' ); if ( array() === $unreadable ) { return array(); } return array( sprintf( /* translators: %s: comma-separated field labels. */ __( 'These Additional Fields could not be read and were not imported: %s.', 'agenticdaisy-calendar' ), implode( ', ', $unreadable ) ), ); } /** * Make sure this site has a custom field for each of TEC's, and return * the mapping from TEC's meta keys to our field names. * * Definitions are created rather than assumed: importing values into * fields nobody has defined would put them straight back where they were * before this release — in the table and on no screen. Existing fields * are matched by name and left alone, so a second import does not * duplicate them or overwrite a label somebody has since edited. * * @return array TEC meta key => this site's field name. */ private function ensure_extra_fields(): array { $tec_fields = array_values( array_filter( $this->reader->additional_fields(), static fn( array $field ): bool => '' !== $field['key'] ) ); if ( array() === $tec_fields ) { return array(); } $existing = \WebCalendar\WP\SiteExtraFields::all(); $by_name = array(); foreach ( $existing as $field ) { $by_name[ $field['name'] ] = true; } $map = array(); $added = array(); foreach ( $tec_fields as $field ) { $name = sanitize_key( $field['label'] ); if ( '' === $name ) { continue; } $map[ $field['key'] ] = $name; if ( ! isset( $by_name[ $name ] ) ) { $definition = array( 'name' => $name, 'label' => $field['label'], 'type' => self::map_field_type( $field['type'] ), ); // Choices come from The Events Calendar's own definition, so a // dropdown arrives offering what it was configured with rather // than only the values events happen to use. if ( 'select' === $definition['type'] ) { $definition['options'] = $field['options']; } $added[] = $definition; $by_name[ $name ] = true; } } if ( array() !== $added ) { \WebCalendar\WP\SiteExtraFields::save( array_merge( $existing, $added ) ); } return $map; } /** * The Events Calendar's field types onto ours. * * Anything unrecognised becomes text, which renders every value legibly * even when it loses a little meaning — the alternative is dropping the * field, and a checkbox shown as text is better than a checkbox lost. */ private static function map_field_type( string $tec_type ): string { switch ( strtolower( trim( $tec_type ) ) ) { case 'textarea': case 'text area': return 'textarea'; case 'url': return 'url'; case 'dropdown': case 'select': case 'radio': // Radio is a select with different chrome: one value from a // fixed list, which is what a select stores. return 'select'; case 'checkbox': // Not our checkbox. The Events Calendar Pro's is a group of // checkboxes with an options list, so an event can hold // several values at once; ours is a single yes/no. There is // nowhere to put more than one value — webcal_site_extras is // keyed (cal_id, cal_name) — so the stored string is kept as // text: legible and complete, rather than a boolean that // discards every choice after the first. return 'text'; default: return 'text'; } } /** * Read, map and import every published TEC event. * * @param bool $append_organizer Append organizer blocks to descriptions. * @param ?string $target_login Shared calendar to own every imported * event; null keeps each event on its * original author's calendar. * @return array{import_id: int, imported: int, duplicates: int, instances: int, categories: int, tags: int, skipped: int, warnings: list} * @throws \InvalidArgumentException When $target_login is not a shared * calendar — before anything is written. * @throws \Throwable On import failure — nothing was written. */ public function run_import( bool $append_organizer, ?string $target_login = null ): array { $this->assert_valid_target( $target_login ); $this->mapper->set_extra_field_map( $this->ensure_extra_fields() ); $batch = $this->map_events( $this->reader->events(), $append_organizer, $target_login ); $report = $this->importer->import( $batch['mapped'], $batch['instance_map'] ); $report['skipped'] = $batch['skipped']; $report['warnings'] = array_merge( $report['warnings'], $batch['warnings'], $this->additional_field_warning() ); return $report; } /** * One sync cycle — Epic 8 (STATUS.md): mirror TEC changes since the * last cycle onto the imported events, then sweep the mirror against * TEC's current published set so deletes, trashes and unpublishes * converge (a modified-since read cannot see them). One-way: TEC wins, * TEC data is never modified. * * @param bool $append_organizer Same meaning as run_import(); * cycles should reuse the * original import's choice. * @param ?string $target_login Same meaning as run_import(). * @param ?DateTimeImmutable $since Only map TEC events modified * at or after this UTC instant; * null maps everything. * @return array{import_id: int, added: int, updated: int, unchanged: int, removed: int, instances: int, categories: int, tags: int, skipped: int, warnings: list} * @throws \InvalidArgumentException When $target_login is not a shared * calendar — before anything is written. * @throws \Throwable On failure — the cycle wrote nothing (the sweep runs * in its own transaction after a successful sync). */ public function run_sync( bool $append_organizer, ?string $target_login = null, ?DateTimeImmutable $since = null ): array { $this->assert_valid_target( $target_login ); $this->mapper->set_extra_field_map( $this->ensure_extra_fields() ); $batch = $this->map_events( $this->reader->events( $since ), $append_organizer, $target_login ); $report = $this->importer->sync( $batch['mapped'], $batch['instance_map'] ); $report['skipped'] = $batch['skipped']; $report['warnings'] = array_merge( $report['warnings'], $batch['warnings'], $this->additional_field_warning() ); // Deletion sweep: mirrored events whose TEC post is no longer // published. The TEC post id is parsed out of the uid rather than // recomputed from the current host so a site-domain change cannot // orphan the whole mirror. $published = array_fill_keys( $this->reader->published_ids(), true ); $orphans = array(); foreach ( $this->importer->mirrored_base_uids() as $uid ) { if ( 1 === preg_match( '/^tec-(\d+)@/', $uid, $m ) && ! isset( $published[ (int) $m[1] ] ) ) { $orphans[] = $uid; } } $report['removed'] += $this->importer->remove( $orphans ); return $report; } /** * @throws \InvalidArgumentException When the target is not a shared calendar. */ private function assert_valid_target( ?string $target_login ): void { if ( null !== $target_login && ! isset( $this->shared_calendars()[ $target_login ] ) ) { throw new \InvalidArgumentException( sprintf( 'Unknown shared calendar "%s".', esc_html( $target_login ) ) ); } } /** * Map TEC events into import-engine input: resolve venues, organizers * and categories, map each event, and collect occurrence lists for * non-convertible recurrences. Shared by run_import() and run_sync(). * * @param list<\WebCalendar\WP\Migration\Tec\TecEvent> $events TEC events. * @return array{mapped: list<\WebCalendar\WP\Migration\Tec\MappedTecEvent>, instance_map: array>, skipped: int, warnings: list} */ private function map_events( array $events, bool $append_organizer, ?string $target_login ): array { $venue_ids = array(); $organizer_ids = array(); foreach ( $events as $event ) { if ( null !== $event->venue_id ) { $venue_ids[] = $event->venue_id; } if ( null !== $event->organizer_id ) { $organizer_ids[] = $event->organizer_id; } } $venues = $this->reader->venues( array_values( array_unique( $venue_ids ) ) ); $organizers = $this->reader->organizers( array_values( array_unique( $organizer_ids ) ) ); $categories = $this->reader->categories(); $mapped = array(); $instance_map = array(); $skipped = 0; $warnings = array(); foreach ( $events as $event ) { $event_categories = array(); foreach ( $event->category_ids as $term_id ) { if ( isset( $categories[ $term_id ] ) ) { $event_categories[] = $categories[ $term_id ]; } } try { $item = $this->mapper->map( $event, null !== $event->venue_id ? ( $venues[ $event->venue_id ] ?? null ) : null, null !== $event->organizer_id ? ( $organizers[ $event->organizer_id ] ?? null ) : null, $event_categories, $target_login ?? $this->resolve_creator_login( $event->author_id ), $append_organizer ); } catch ( \InvalidArgumentException $e ) { ++$skipped; $warnings[] = sprintf( /* translators: 1: event title, 2: error detail. */ __( 'Skipped "%1$s": %2$s', 'agenticdaisy-calendar' ), $event->title, $e->getMessage() ); continue; } if ( $item->needs_instance_expansion ) { $occurrences = $this->mapper->map_occurrences( $event, $this->reader->occurrences( $event->id ) ); if ( array() !== $occurrences ) { $instance_map[ $item->event->uid() ] = $occurrences; } } $mapped[] = $item; } return array( 'mapped' => $mapped, 'instance_map' => $instance_map, 'skipped' => $skipped, 'warnings' => $warnings, ); } /** * The webcal login an imported event belongs to: the TEC post author's * login, or the importing user when the author no longer exists. * * @throws \RuntimeException When no importing user is available either — * cannot happen in wp-admin (the capability check guarantees a * user), but a CLI caller without --user would otherwise create * ownerless events. */ public function resolve_creator_login( int $author_id ): string { if ( $author_id > 0 ) { $user = get_userdata( $author_id ); if ( false !== $user && '' !== $user->user_login ) { return $user->user_login; } } // The anonymous user (WP-CLI without --user) reports user_login as // false, not '' — observed live — so gate on exists() rather than // trusting the property's stubbed string type. $current = wp_get_current_user(); if ( $current->exists() && '' !== $current->user_login ) { return $current->user_login; } throw new \RuntimeException( 'No importing user available to own events whose TEC author no longer exists.' ); } private function render_analysis( string $error = '' ): void { if ( '' !== $error ) { echo '

' . esc_html( $error ) . '

'; } $sync_notice = $this->scheduler()->settings()['notice']; if ( null !== $sync_notice ) { echo '

' . esc_html( $sync_notice ) . '

'; } $analysis = $this->analyze(); ?>

' . esc_html__( 'No data from The Events Calendar was found in this site\'s database.', 'agenticdaisy-calendar' ) . '

'; $this->render_previous_runs(); return; } $publish = $analysis['status_counts']['publish'] ?? 0; $other = array_sum( $analysis['status_counts'] ) - $publish; // Said before the import, not after: the point is to let somebody // decide, and a note in the summary of a completed run is too late to // act on. Events Calendar Pro's Additional Fields are configured // deliberately, so losing them without a word is worse than declining // to carry them. $extra_fields = $this->reader->additional_fields(); if ( array() !== $extra_fields ) { $readable = array_values( array_filter( $extra_fields, static fn( array $f ): bool => '' !== $f['key'] ) ); $labels = array_column( $readable, 'label' ); if ( array() !== $labels ) { echo '

' . esc_html( sprintf( /* translators: %s: comma-separated field labels. */ __( 'Additional Fields from The Events Calendar Pro will be imported as custom fields: %s. Matching fields are created under Calendar → Custom Fields if they do not exist.', 'agenticdaisy-calendar' ), implode( ', ', $labels ) ) ) . '

'; } // A definition with no storage key cannot be read at all, so say // so separately rather than implying it will come across. $unreadable = array_column( array_values( array_filter( $extra_fields, static fn( array $f ): bool => '' === $f['key'] ) ), 'label' ); if ( array() !== $unreadable ) { echo '

' . esc_html( sprintf( /* translators: %s: comma-separated field labels. */ __( 'These Additional Fields cannot be read and will not be imported: %s.', 'agenticdaisy-calendar' ), implode( ', ', $unreadable ) ) ) . '

'; } } ?>


shared_calendars(); ?>

__( 'Importing…', 'agenticdaisy-calendar' ) ) ); ?>
render_sync_settings(); $this->render_previous_runs(); } /** * The side-by-side sync box: keep the mirror fresh on a schedule so * WebCalendar can run next to TEC for comparison — Epic 8 (STATUS.md). */ private function render_sync_settings(): void { $settings = $this->scheduler()->settings(); ?>


shared_calendars(); ?>

__( 'Syncing…', 'agenticdaisy-calendar' ) ) ); ?>
scheduler()->run_now(); } catch ( \Throwable $e ) { $this->render_analysis( sprintf( /* translators: %s: error detail. */ __( 'Sync failed; nothing was written: %s', 'agenticdaisy-calendar' ), $e->getMessage() ) ); return; } echo '

' . esc_html( sprintf( /* translators: 1: added, 2: updated, 3: removed, 4: unchanged. */ __( 'Sync complete: %1$d added, %2$d updated, %3$d removed, %4$d unchanged.', 'agenticdaisy-calendar' ), $report['added'], $report['updated'], $report['removed'], $report['unchanged'] ) ) . '

'; $this->render_analysis(); } private function handle_sync_settings(): void { check_admin_referer( self::NONCE ); if ( ! current_user_can( 'webcal_can_import' ) ) { wp_die( esc_html__( 'You do not have permission to import.', 'agenticdaisy-calendar' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $enabled = isset( $_POST['webcal_tec_sync_enabled'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $interval = isset( $_POST['webcal_tec_sync_interval'] ) && is_string( $_POST['webcal_tec_sync_interval'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_tec_sync_interval'] ) ) : 'hourly'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $organizer = isset( $_POST['webcal_tec_sync_organizer'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $target = isset( $_POST['webcal_tec_sync_target'] ) && is_string( $_POST['webcal_tec_sync_target'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_tec_sync_target'] ) ) : ''; // The POST value is forgeable; only accept a real shared calendar. $target_login = '' !== $target && isset( $this->shared_calendars()[ $target ] ) ? $target : null; try { $this->scheduler()->configure( $enabled, $interval, $organizer, $target_login ); } catch ( \InvalidArgumentException $e ) { $this->render_analysis( $e->getMessage() ); return; } echo '

' . esc_html( $enabled ? __( 'Sync is on. WebCalendar will keep mirroring The Events Calendar — keep both plugins active while you compare.', 'agenticdaisy-calendar' ) : __( 'Sync is off. Mirrored events stay as they are.', 'agenticdaisy-calendar' ) ) . '

'; $this->render_analysis(); } private function handle_remove_all(): void { check_admin_referer( self::NONCE ); if ( ! current_user_can( 'webcal_can_import' ) ) { wp_die( esc_html__( 'You do not have permission to import.', 'agenticdaisy-calendar' ) ); } // Leaving sync on would re-mirror everything on the next cycle, // silently undoing the removal — turn it off first. $settings = $this->scheduler()->settings(); if ( $settings['enabled'] ) { $this->scheduler()->configure( false, $settings['interval'], $settings['append_organizer'], $settings['target_login'] ); echo '

' . esc_html__( 'Sync was turned off so the removed events do not come back on the next cycle.', 'agenticdaisy-calendar' ) . '

'; } // The mirror is going away, so the incremental window must go with // it: a surviving last_sync would make a later re-enable mirror // only what changed in the meantime and miss everything else. $this->scheduler()->forget_window(); $removed = $this->importer->remove_all(); echo '

' . esc_html( sprintf( /* translators: %d: number of removed events. */ __( 'Removed all %d events imported or synced from The Events Calendar. Categories that were created are kept.', 'agenticdaisy-calendar' ), $removed ) ) . '

'; $this->render_analysis(); } private function handle_run(): void { check_admin_referer( self::NONCE ); if ( ! current_user_can( 'webcal_can_import' ) ) { wp_die( esc_html__( 'You do not have permission to import.', 'agenticdaisy-calendar' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $append_organizer = isset( $_POST['webcal_tec_organizer'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $target_choice = isset( $_POST['webcal_tec_target'] ) && is_string( $_POST['webcal_tec_target'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_tec_target'] ) ) : 'author'; $target_login = null; if ( 'shared' === $target_choice ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $target_login = isset( $_POST['webcal_tec_shared_login'] ) && is_string( $_POST['webcal_tec_shared_login'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_tec_shared_login'] ) ) : ''; // run_import() validates the login against the shared-calendar // list before writing anything (the POST value is forgeable). } try { $report = $this->run_import( $append_organizer, $target_login ); } catch ( \Throwable $e ) { $this->render_analysis( sprintf( /* translators: %s: error detail. */ __( 'The import could not be completed and no events were imported: %s', 'agenticdaisy-calendar' ), $e->getMessage() ) ); return; } ?>

0 ) : ?>

scheduler()->settings()['enabled'] ) : ?>

render_sync_settings(); $this->render_previous_runs(); } private function handle_rollback(): void { check_admin_referer( self::NONCE ); if ( ! current_user_can( 'webcal_can_import' ) ) { wp_die( esc_html__( 'You do not have permission to import.', 'agenticdaisy-calendar' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $import_id = isset( $_POST['webcal_import_id'] ) && is_scalar( $_POST['webcal_import_id'] ) ? absint( $_POST['webcal_import_id'] ) : 0; $removed = $this->importer->rollback( $import_id ); echo '

' . esc_html( sprintf( /* translators: %d: number of removed events. */ __( 'Removed %d imported events.', 'agenticdaisy-calendar' ), $removed ) ) . '

'; $this->render_analysis(); } private function render_previous_runs(): void { $runs = $this->importer->list_runs(); if ( array() === $runs ) { return; } ?>

"return confirm('" . esc_js( __( 'This permanently removes every event this import created. Continue?', 'agenticdaisy-calendar' ) ) . "');" ) ); ?>
"return confirm('" . esc_js( __( 'This permanently removes every event imported or synced from The Events Calendar, across all runs, and turns off sync. The Events Calendar\'s own data is not touched. Continue?', 'agenticdaisy-calendar' ) ) . "');" ) ); ?>