register_cpt();
flush_rewrite_rules();
} );
final class Speakers_For_TEC {
const CPT = 'tec_speaker';
const META_KEY = '_tec_event_speakers';
const PICKER_LIMIT = 200;
// -------------------------------------------------------------------------
// Boot
// -------------------------------------------------------------------------
public function __construct() {
add_action( 'init', [ $this, 'register_cpt' ] );
add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
add_action( 'save_post_' . self::CPT, [ $this, 'save_speaker_meta' ], 10, 2 );
add_action( 'save_post_tribe_events', [ $this, 'save_event_meta' ], 10, 2 );
add_action( 'admin_menu', [ $this, 'add_admin_menu' ] );
add_action( 'admin_post_sftec_import', [ $this, 'handle_csv_import' ] );
add_action( 'admin_notices', [ $this, 'admin_notices' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] );
add_shortcode( 'tec_speakers', [ $this, 'speakers_shortcode' ] );
if ( class_exists( 'Tribe__Events__Main' ) ) {
add_action( 'tribe_events_single_event_after_the_meta', [ $this, 'display_event_speakers' ] );
} else {
add_action( 'admin_notices', [ $this, 'notice_tec_missing' ] );
}
}
// -------------------------------------------------------------------------
// Custom post type
// -------------------------------------------------------------------------
public function register_cpt(): void {
register_post_type( self::CPT, [
'labels' => [
'name' => __( 'Speakers', 'speakers-for-tec' ),
'singular_name' => __( 'Speaker', 'speakers-for-tec' ),
'add_new_item' => __( 'Add New Speaker', 'speakers-for-tec' ),
'edit_item' => __( 'Edit Speaker', 'speakers-for-tec' ),
'new_item' => __( 'New Speaker', 'speakers-for-tec' ),
'view_item' => __( 'View Speaker', 'speakers-for-tec' ),
'search_items' => __( 'Search Speakers', 'speakers-for-tec' ),
'not_found' => __( 'No speakers found', 'speakers-for-tec' ),
'not_found_in_trash' => __( 'No speakers in trash', 'speakers-for-tec' ),
'all_items' => __( 'All Speakers', 'speakers-for-tec' ),
'menu_name' => __( 'Speakers', 'speakers-for-tec' ),
],
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'query_var' => true,
'rewrite' => [ 'slug' => 'speakers' ],
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 6,
'menu_icon' => 'dashicons-groups',
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'show_in_rest' => true,
] );
}
// -------------------------------------------------------------------------
// Dependency notice
// -------------------------------------------------------------------------
public function notice_tec_missing(): void {
echo '
'
. esc_html__( 'Speakers for The Events Calendar requires The Events Calendar plugin to be installed and active.', 'speakers-for-tec' )
. '
';
}
// -------------------------------------------------------------------------
// Meta boxes
// -------------------------------------------------------------------------
public function add_meta_boxes(): void {
add_meta_box(
'sftec_speaker_details',
__( 'Speaker Details', 'speakers-for-tec' ),
[ $this, 'render_speaker_details_meta' ],
self::CPT,
'side',
'default'
);
if ( post_type_exists( 'tribe_events' ) ) {
add_meta_box(
'sftec_event_speakers',
__( 'Speakers', 'speakers-for-tec' ),
[ $this, 'render_event_speakers_meta' ],
'tribe_events',
'normal',
'default'
);
}
}
public function render_speaker_details_meta( \WP_Post $post ): void {
wp_nonce_field( 'sftec_speaker_details_save', '_sftec_speaker_nonce' );
$website = get_post_meta( $post->ID, '_tec_speaker_website', true );
$email = get_post_meta( $post->ID, '_tec_speaker_email', true );
?>
ID, self::META_KEY, true ) ?: [] );
$speakers = get_posts( [
'post_type' => self::CPT,
'posts_per_page' => self::PICKER_LIMIT,
'orderby' => 'title',
'order' => 'ASC',
'post_status' => 'publish',
] );
if ( empty( $speakers ) ) {
printf(
'%s %s
',
esc_html__( 'No speakers yet.', 'speakers-for-tec' ),
esc_url( admin_url( 'post-new.php?post_type=' . self::CPT ) ),
esc_html__( 'Add speakers first, then return here to attach them.', 'speakers-for-tec' )
);
return;
}
echo '';
echo ''
. esc_html__( 'Type to search. Click a name to add, click again to remove.', 'speakers-for-tec' )
. '
';
}
// -------------------------------------------------------------------------
// Saving meta
// -------------------------------------------------------------------------
public function save_speaker_meta( int $post_id, \WP_Post $post ): void {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if (
! isset( $_POST['_sftec_speaker_nonce'] )
|| ! wp_verify_nonce( $_POST['_sftec_speaker_nonce'], 'sftec_speaker_details_save' )
|| ! current_user_can( 'edit_post', $post_id )
) {
return;
}
update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( $_POST['sftec_speaker_website'] ?? '' ) );
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( $_POST['sftec_speaker_email'] ?? '' ) );
}
public function save_event_meta( int $post_id, \WP_Post $post ): void {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if (
! isset( $_POST['_sftec_event_speakers_nonce'] )
|| ! wp_verify_nonce( $_POST['_sftec_event_speakers_nonce'], 'sftec_event_speakers_save' )
|| ! current_user_can( 'edit_post', $post_id )
) {
return;
}
$ids = array_map( 'intval', (array) ( $_POST['sftec_event_speakers'] ?? [] ) );
update_post_meta( $post_id, self::META_KEY, array_filter( $ids ) );
}
// -------------------------------------------------------------------------
// Front-end: speakers on event pages
// -------------------------------------------------------------------------
public function display_event_speakers(): void {
$post_id = get_the_ID();
$speaker_ids = (array) ( get_post_meta( $post_id, self::META_KEY, true ) ?: [] );
if ( empty( $speaker_ids ) ) {
return;
}
$speakers = get_posts( [
'post_type' => self::CPT,
'post__in' => $speaker_ids,
'posts_per_page' => -1,
'orderby' => 'post__in',
'post_status' => 'publish',
] );
if ( empty( $speakers ) ) {
return;
}
$heading = count( $speakers ) > 1
? __( 'Speakers', 'speakers-for-tec' )
: __( 'Speaker', 'speakers-for-tec' );
echo '';
echo '' . esc_html( $heading ) . '
';
echo '';
foreach ( $speakers as $speaker ) {
$this->render_speaker_card( $speaker, 'event' );
}
echo '
';
}
// -------------------------------------------------------------------------
// Shortcode: [tec_speakers limit="20" search=""]
// -------------------------------------------------------------------------
public function speakers_shortcode( array $atts ): string {
$atts = shortcode_atts( [ 'limit' => 50, 'search' => '' ], $atts, 'tec_speakers' );
$args = [
'post_type' => self::CPT,
'posts_per_page' => (int) $atts['limit'],
'orderby' => 'title',
'order' => 'ASC',
'post_status' => 'publish',
];
if ( $atts['search'] ) {
$args['s'] = sanitize_text_field( $atts['search'] );
}
$speakers = get_posts( $args );
if ( empty( $speakers ) ) {
return '' . esc_html__( 'No speakers found.', 'speakers-for-tec' ) . '
';
}
ob_start();
echo '';
foreach ( $speakers as $speaker ) {
$this->render_speaker_card( $speaker, 'archive' );
}
echo '
';
return ob_get_clean();
}
// -------------------------------------------------------------------------
// Shared card renderer
// -------------------------------------------------------------------------
private function render_speaker_card( \WP_Post $speaker, string $context = 'event' ): void {
$permalink = get_permalink( $speaker->ID );
$website = get_post_meta( $speaker->ID, '_tec_speaker_website', true );
echo '';
if ( has_post_thumbnail( $speaker->ID ) ) {
echo '';
}
echo '';
echo '';
}
// -------------------------------------------------------------------------
// Styles — registered through WordPress, not echoed inline
// -------------------------------------------------------------------------
public function enqueue_styles(): void {
wp_register_style( 'sftec-speakers', false, [], '1.0.0' );
wp_enqueue_style( 'sftec-speakers' );
wp_add_inline_style( 'sftec-speakers', $this->get_css() );
}
private function get_css(): string {
return '
.sftec-event-speakers { margin: 2em 0; border-top: 1px solid #e0e0e0; padding-top: 1.5em; }
.sftec-speakers-heading { font-size: 1.15em; font-weight: 600; margin: 0 0 1em; }
.sftec-speakers-list,
.sftec-speakers-archive { display: flex; flex-wrap: wrap; gap: 1.25em; }
.sftec-speaker-card { display: flex; gap: 0.85em; align-items: flex-start; flex: 1 1 260px; max-width: 420px; }
.sftec-speaker-photo img { border-radius: 50%; width: 64px; height: 64px; object-fit: cover; display: block; }
.sftec-speaker-info { flex: 1; min-width: 0; }
.sftec-speaker-name { margin: 0 0 0.3em; font-size: 1em; font-weight: 600; }
.sftec-speaker-name a { text-decoration: none; color: inherit; }
.sftec-speaker-name a:hover { text-decoration: underline; }
.sftec-speaker-bio { margin: 0 0 0.4em; font-size: 0.875em; color: #555; line-height: 1.4; }
.sftec-speaker-link { font-size: 0.825em; }
';
}
// -------------------------------------------------------------------------
// Admin: Select2 on event edit screens
// -------------------------------------------------------------------------
public function admin_enqueue_scripts( string $hook ): void {
$screen = get_current_screen();
if ( ! $screen || $screen->post_type !== 'tribe_events' || ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) {
return;
}
// TEC ships SelectWoo as 'tribe-select2'; fall back to a plain 'select2'
// registration from another plugin, then finally our own CDN copy.
if ( wp_script_is( 'tribe-select2', 'registered' ) ) {
wp_enqueue_script( 'tribe-select2' );
wp_enqueue_style( 'tribe-select2-css' );
$handle = 'tribe-select2';
} elseif ( wp_script_is( 'select2', 'registered' ) ) {
wp_enqueue_script( 'select2' );
wp_enqueue_style( 'select2' );
$handle = 'select2';
} else {
wp_enqueue_script(
'sftec-select2',
'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js',
[ 'jquery' ],
'4.1.0',
true
);
wp_enqueue_style(
'sftec-select2',
'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css',
[],
'4.1.0'
);
$handle = 'sftec-select2';
}
wp_add_inline_script(
$handle,
"jQuery(function($){
$('#sftec-speaker-select').select2({
placeholder: '" . esc_js( __( 'Search for a speaker…', 'speakers-for-tec' ) ) . "',
allowClear: true,
width: '100%'
});
});"
);
}
// -------------------------------------------------------------------------
// Admin: CSV import page
// -------------------------------------------------------------------------
public function add_admin_menu(): void {
add_submenu_page(
'edit.php?post_type=' . self::CPT,
__( 'Import Speakers from CSV', 'speakers-for-tec' ),
__( 'Import CSV', 'speakers-for-tec' ),
'manage_options',
'sftec-import-speakers',
[ $this, 'render_import_page' ]
);
}
public function render_import_page(): void {
?>
name, bio, email, website, image
- name —
- bio —
- email —
- website —
- image —
'no_file' ], 60 );
wp_redirect( $redirect_url );
exit;
}
// Validate MIME type server-side — browser accept=".csv" is not a security control
$finfo = new \finfo( FILEINFO_MIME_TYPE );
$mime = $finfo->file( $_FILES['speakers_csv']['tmp_name'] );
if ( ! in_array( $mime, [ 'text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel' ], true ) ) {
set_transient( $transient_key, [ 'err' => 'invalid_type' ], 60 );
wp_redirect( $redirect_url );
exit;
}
$handle = fopen( $_FILES['speakers_csv']['tmp_name'], 'r' );
if ( ! $handle ) {
set_transient( $transient_key, [ 'err' => 'read_fail' ], 60 );
wp_redirect( $redirect_url );
exit;
}
$skip_header = ! empty( $_POST['skip_header'] );
// Build name index to prevent duplicates
$existing_ids = get_posts( [
'post_type' => self::CPT,
'posts_per_page' => -1,
'fields' => 'ids',
'post_status' => 'any',
] );
$existing_names = [];
foreach ( $existing_ids as $id ) {
$existing_names[ $this->normalise_name( get_the_title( $id ) ) ] = true;
}
$created = $skipped = $errors = 0;
$row_num = 0;
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
$row_num++;
if ( $skip_header && $row_num === 1 ) {
continue;
}
if ( empty( array_filter( $row ) ) ) {
continue;
}
[ $name, $bio, $email, $website ] = array_pad( $row, 4, '' );
$name = trim( $name );
if ( ! $name ) {
continue;
}
$key = $this->normalise_name( $name );
if ( isset( $existing_names[ $key ] ) ) {
$skipped++;
continue;
}
$bio = sanitize_textarea_field( $bio );
$post_id = wp_insert_post( [
'post_type' => self::CPT,
'post_title' => sanitize_text_field( $name ),
'post_content' => $bio,
'post_excerpt' => wp_trim_words( $bio, 30 ),
'post_status' => 'publish',
] );
if ( is_wp_error( $post_id ) ) {
$errors++;
continue;
}
if ( $email ) {
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( trim( $email ) ) );
}
if ( $website ) {
update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( trim( $website ) ) );
}
$existing_names[ $key ] = true;
$created++;
}
fclose( $handle );
set_transient( $transient_key, [
'created' => $created,
'skipped' => $skipped,
'errors' => $errors,
], 60 );
wp_redirect( $redirect_url );
exit;
}
private function normalise_name( string $name ): string {
$name = strtolower( trim( $name ) );
$name = preg_replace( '/^(dr\.?|prof\.?|mr\.?|mrs\.?|ms\.?|miss\.?)\s+/u', '', $name ) ?? $name;
return preg_replace( '/\s+/', ' ', $name ) ?? $name;
}
// -------------------------------------------------------------------------
// Admin notices: import result banner
// -------------------------------------------------------------------------
public function admin_notices(): void {
$transient_key = 'sftec_import_result_' . get_current_user_id();
$result = get_transient( $transient_key );
if ( ! is_array( $result ) ) {
return;
}
delete_transient( $transient_key );
if ( isset( $result['err'] ) ) {
$msgs = [
'no_file' => __( 'No file was uploaded.', 'speakers-for-tec' ),
'read_fail' => __( 'Could not read the uploaded file.', 'speakers-for-tec' ),
'invalid_type' => __( 'The uploaded file is not a valid CSV.', 'speakers-for-tec' ),
];
$msg = $msgs[ $result['err'] ] ?? __( 'An unknown error occurred.', 'speakers-for-tec' );
printf(
'',
esc_html__( 'Import failed:', 'speakers-for-tec' ),
esc_html( $msg )
);
return;
}
printf(
'',
wp_kses(
sprintf(
/* translators: 1: number of speakers created, 2: number skipped, 3: number of errors */
__( 'Import complete — %1$d created, %2$d skipped (already exist), %3$d errors.', 'speakers-for-tec' ),
(int) $result['created'],
(int) $result['skipped'],
(int) $result['errors']
),
[ 'strong' => [] ]
)
);
}
}
add_action( 'plugins_loaded', fn() => new Speakers_For_TEC() );