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 )
)
) . '
';
}
}
?>
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();
?>
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;
}
?>