Migration, import and export hooks

HappenBoard ships migration adapters for five events plugins (The Events Calendar, Events Manager, Sugar Calendar, EventKoi, Amelia) and a JSON settings backup. Both are extensible: one filter registers a new migration source, two actions let you ride along with an import in progress, and two filters extend the settings export and import files.

Everything on this page lives in the free plugin. The two progress actions are fired by the The Events Calendar adapter only, which is where Pro hooks its own custom-fields import.

happenboard_migration_adapters

Filter Free Since 1.0 src/Service/Migration/Registry.php

Registers a migration source for a plugin HappenBoard does not support out of the box. The filter runs inside Registry::all() every time the admin lists sources or runs a batch, and whatever you append becomes a first-class source in the Migration screen and in the wp happenboard migrate command.

Signature

apply_filters( 'happenboard_migration_adapters', array $adapters )

Parameters

$adapters array
The five built-in adapter instances, in this order: TheEventsCalendar, EventsManager, SugarCalendar, EventKoi, Amelia. It is a plain list (integer keys), not a map.

Return value

array The adapter list. Every entry must be an instance of HappenBoard\Service\Migration\MigratorAdapter; the Registry re-keys the list by $adapter->id() and drops anything that fails the instanceof test.

Example

add_filter(
    'happenboard_migration_adapters',
    static function ( array $adapters ): array {
        $adapters[] = new My_Calendar_Adapter();

        return $adapters;
    }
);

/**
 * A third-party migration source.
 *
 * The interface is \HappenBoard\Service\Migration\MigratorAdapter
 * (src/Service/Migration/MigratorAdapter.php). All seven methods are
 * required. The MigrationHelpers trait is optional but gives you the
 * idempotence bookkeeping every built-in adapter uses.
 */
final class My_Calendar_Adapter implements \HappenBoard\Service\Migration\MigratorAdapter {

    use \HappenBoard\Service\Migration\MigrationHelpers;

    private const SOURCE_ID = 'my-calendar';

    /** Stable id: lowercase + dashes. Used in the REST route and in the
     *  `_happenboard_migrated_from` meta value. Must not collide with a
     *  built-in id or you silently replace that adapter. */
    public function id(): string {
        return self::SOURCE_ID;
    }

    public function name(): string {
        return __( 'My Calendar', 'my-plugin' );
    }

    public function description(): string {
        return __( 'Import events from My Calendar.', 'my-plugin' );
    }

    /** Absolute URL, or '' to let the UI render a text initial. */
    public function logo_url(): string {
        return '';
    }

    /** Return false and the source is listed but cannot be run: both the
     *  REST route and the WP-CLI command abort on this check. */
    public function detect(): bool {
        return post_type_exists( 'mycal_event' );
    }

    /** Counts shown on the source card. Only called when detect() is true.
     *  Known keys: events, venues, organizers, categories, attendees. */
    public function summary(): array {
        return array(
            'events' => (int) wp_count_posts( 'mycal_event' )->publish,
        );
    }

    /** One batch. The UI calls this repeatedly until $result->done. */
    public function migrate( array $options ): \HappenBoard\Service\Migration\Result {
        $result = new \HappenBoard\Service\Migration\Result();
        $dry    = ! empty( $options['dry_run'] );
        $batch  = max( 1, (int) ( $options['batch_size'] ?? 50 ) );
        $offset = max( 0, (int) ( $options['offset'] ?? 0 ) );

        $source_ids = get_posts(
            array(
                'post_type'      => 'mycal_event',
                'post_status'    => 'any',
                'posts_per_page' => $batch,
                'offset'         => $offset,
                'orderby'        => 'ID',
                'order'          => 'ASC',
                'fields'         => 'ids',
            )
        );

        foreach ( $source_ids as $source_id ) {
            // Re-runs must be safe: skip anything already tagged.
            if ( $this->find_migrated( self::SOURCE_ID, 'hboard_event', $source_id ) > 0 ) {
                $result->count_skipped( 'events' );
                continue;
            }

            // Preview run: count it, write nothing.
            if ( $dry ) {
                $result->count_created( 'events' );
                continue;
            }

            $new_id = wp_insert_post(
                array(
                    'post_type'    => 'hboard_event',
                    'post_status'  => get_post_status( $source_id ),
                    'post_title'   => get_the_title( $source_id ),
                    'post_content' => (string) get_post_field( 'post_content', $source_id ),
                ),
                true
            );

            if ( is_wp_error( $new_id ) ) {
                $result->count_failed( 'events' );
                $result->add_error( 'events', $source_id, $new_id->get_error_message() );
                continue;
            }

            // Dates are stored as UTC ISO strings; the trait converts from
            // a local datetime using the site timezone.
            $start = (string) get_post_meta( $source_id, 'mycal_start', true );
            update_post_meta( $new_id, '_happenboard_start', $this->local_to_utc_iso( $start ) );
            update_post_meta( $new_id, '_happenboard_end', $this->local_to_utc_iso( $start ) );

            // Occurrence rows are built from the date meta, which did not
            // exist when wp_insert_post fired save_post. Rebuild them now.
            ( new \HappenBoard\Service\Events\OccurrenceSyncer() )->sync( (int) $new_id );

            $this->mark_migrated( $new_id, self::SOURCE_ID, $source_id );
            $result->count_created( 'events' );
        }

        $result->next_offset = $offset + count( $source_ids );
        $result->done        = count( $source_ids ) < $batch;

        return $result;
    }
}

Notes

  • The Registry indexes the filtered list by $adapter->id(), last one wins. Returning an adapter whose id() is the-events-calendar, events-manager, sugar-calendar, eventkoi or amelia silently replaces that built-in adapter, with no warning. That is also how you deliberately override one.
  • Entries that are not MigratorAdapter instances are skipped without any error: a typo in the interface name or a class that only implements part of it means your source never shows up. Return value must be an array too, the Registry iterates it with no is_array() guard.
  • Registry::all() is also called by get() and detected(), so the filter fires several times per request and your adapter is instantiated each time. Keep the constructor empty and do the database work in summary() and migrate().
  • detect() is a gate, not a hint. POST /happenboard/v1/migration/{source} returns a 400 happenboard_source_not_detected and the WP-CLI command aborts when it returns false, so an adapter that cannot detect its source can be listed but never run.
  • The two progress actions on this page are fired by the The Events Calendar adapter’s own code, not by the Registry or by any shared base class. If you want third parties to extend your adapter, fire happenboard_migration_before_events and happenboard_migration_event_imported yourself, with the same argument order.

happenboard_migration_before_events

Action Free Since 1.0 src/Service/Migration/Sources/TheEventsCalendar.php

Fires at the top of every event batch, after the batch’s post IDs are fetched but before the first event is read or written. Use it to prepare anything the per-event pass will need, such as a field map or a set of definitions. Pro’s custom-fields importer seeds its TEC field definitions here.

Signature

do_action( 'happenboard_migration_before_events', string $source_id, bool $dry )

Parameters

$source_id string
The adapter id. Always the-events-calendar today, since no other built-in adapter fires this action. Test it anyway so your callback stays correct if another source starts firing it.
$dry bool
True when the operator asked for a preview (dry_run in the REST body, --dry-run on the CLI). Nothing must be persisted in that case.

Example

add_action(
    'happenboard_migration_before_events',
    static function ( string $source_id, bool $dry ): void {
        if ( 'the-events-calendar' !== $source_id ) {
            return;
        }

        // Build whatever the per-event pass will need, in memory.
        $map = my_plugin_build_field_map();

        // A preview must not touch the database. The per-event action does
        // not fire at all on a dry run, so this is your only chance to
        // report what a real run would do.
        if ( $dry ) {
            my_plugin_cache_field_map( $map );
            return;
        }

        my_plugin_persist_field_map( $map );
    },
    10,
    2
);

Notes

  • Two arguments, so pass 10, 2 to add_action(). With the default accepted-args of 1, a callback typed function ( string $source_id, bool $dry ) throws an ArgumentCountError and takes the whole migration request down.
  • It does fire during a dry run. The call sits before the per-event loop, above the if ( $dry ) shortcut, and $dry is passed precisely so you can build your state in memory and skip the writes. Pro does exactly that: it resolves the field map, then returns before saving when $dry is true.
  • It fires once per batch, not once per migration. The admin UI keeps posting to the migration route until the adapter reports done, so a 900 event import at the default batch size fires this eighteen times. Make the callback idempotent and cheap.
  • Only the The Events Calendar adapter fires it. Events Manager, Sugar Calendar, EventKoi and Amelia run their event loops without it, so a listener never runs for those sources even though the signature looks source-agnostic.
  • It is skipped entirely when the run’s include list leaves out events, and it still fires when the batch came back empty (the call is not guarded on the post list being non-empty).

happenboard_migration_event_imported

Action Free Since 1.0 src/Service/Migration/Sources/TheEventsCalendar.php

Fires after one source event has been inserted as an hboard_event and tagged as migrated. Use it to copy ancillary per-event data the core adapter does not know about, and to report what you did back into the same migration response through the Result handle.

Signature

do_action( 'happenboard_migration_event_imported', int $new_id, int $source_id, string $adapter_id, Result $result )

Parameters

$new_id int
The newly created hboard_event post ID. Always a real ID: the WP_Error branch of wp_insert_post() continues the loop before this action.
$source_id int
The source post ID, a The Events Calendar tribe_events post. This is where the untouched original meta still lives.
$adapter_id string
The adapter id, the-events-calendar. Check it before doing any work so your callback stays inert for other sources.
$result Result
The live HappenBoard\Service\Migration\Result accumulator for this batch. Its count_created(), count_skipped(), count_failed() and add_error() methods write straight into the REST response the browser is waiting on.

Example

add_action(
    'happenboard_migration_event_imported',
    static function (
        int $new_id,
        int $source_id,
        string $adapter_id,
        \HappenBoard\Service\Migration\Result $result
    ): void {
        if ( 'the-events-calendar' !== $adapter_id ) {
            return;
        }

        // $source_id is the tribe_events post, $new_id the hboard_event.
        $cost = get_post_meta( $source_id, '_EventCost', true );
        if ( '' === $cost || null === $cost ) {
            return;
        }

        $clean = sanitize_text_field( (string) $cost );
        if ( ! is_numeric( $clean ) ) {
            // Surfaces as a row in the migration UI's error list.
            $result->add_error( 'events', $source_id, 'Unparseable cost value.' );
            return;
        }

        update_post_meta( $new_id, '_my_plugin_price', $clean );

        // Adds a "prices" counter card to the same REST response.
        $result->count_created( 'prices' );
    },
    10,
    4
);

Notes

  • Four arguments, so add_action( ..., 10, 4 ). Anything less and a typed callback fatals mid-import, leaving the run half finished.
  • It never fires on a dry run. The if ( $dry ) branch counts the event and continues before the insert, so this action only ever sees real writes. Anything you want to appear in a preview has to be estimated from happenboard_migration_before_events instead.
  • The second parameter is an integer post ID despite being named $source_id; the adapter id string is the third parameter, $adapter_id. Swapping them is the usual mistake and it fails silently, since both source and adapter checks then never match.
  • $result is an object, so your mutations survive into the response. add_error() puts a row in the migration UI’s error list, and count_created() with a resource name the core adapter never uses creates a brand new counter on the fly (Result initialises unknown buckets on first access).
  • It only fires for events that were actually inserted. Events already carrying _happenboard_migrated_from hit the skip branch far earlier, so a second run over the same site gives you no second pass on existing rows. It also fires before the event’s own created counter is bumped, and only from the The Events Calendar adapter.

happenboard_settings_backup_export

Filter Free Since 1.0 src/Service/IO/SettingsBackup.php

Adds your own sections to the settings backup document that Tools exports as JSON. The filter runs at the end of SettingsBackup::export(), once the base payload has been assembled, and whatever you attach travels with the file to the site that imports it.

Signature

apply_filters( 'happenboard_settings_backup_export', array $payload )

Parameters

$payload array
The base document, with the keys format (always happenboard.settings), version (currently 1), plugin, site, exportedAt and general.

Return value

array The full export document. Add your data under your own top-level key and return the whole array. A non-array return is replaced by an empty array, which produces an empty download with no error shown to the operator.

Example

add_filter(
    'happenboard_settings_backup_export',
    static function ( array $payload ): array {
        $settings = (array) get_option( 'my_plugin_settings', array() );

        // The operator downloads this file and mails it around. Strip
        // every credential before it leaves the site.
        unset( $settings['api_secret'], $settings['webhook_token'] );

        // Own top-level key. Never touch format / version / plugin /
        // site / exportedAt / general.
        $payload['my_plugin'] = $settings;

        // Always return the array you were given: a non-array return
        // makes export() hand back an empty document, with no error.
        return $payload;
    }
);

Notes

  • Single argument, so no accepted-args value is needed. Namespace your data under one top-level key and leave format, version, plugin, site, exportedAt and general alone. Changing format makes the file unimportable: import() rejects anything whose format is not happenboard.settings.
  • Always return the array you were given. export() ends with is_array( $payload ) ? $payload : array(), so a callback that forgets its return statement hands the operator an empty backup with no warning anywhere.
  • The docblock says secrets must never be included, but the base payload already breaks that rule: general is GeneralRepository::all(), the raw option, which still contains the four write-only keys (turnstile_secret_key, akismet_api_key, google_maps_api_key, weather_api_key). The masked public_view() is not used here. Treat the exported file as a secret, and do not add yours to it.
  • Pro does not listen on this filter. The class docblock claims Pro adds a pro block, but nothing in HappenBoard Pro hooks either backup filter, so Pro settings are absent from the export. Do not rely on a pro key being present when you write an importer.
  • The document is JSON encoded by the REST layer, so everything you attach must be JSON serialisable. Objects, closures and resources are lost or break the response.

happenboard_settings_backup_import

Filter Free Since 1.0 src/Service/IO/SettingsBackup.php

Restores your own sections from an uploaded settings backup and reports which ones you applied. It runs at the end of SettingsBackup::import(), after the format and version checks pass and after HappenBoard has written its own general block.

Signature

apply_filters( 'happenboard_settings_backup_import', array $applied, array $payload )

Parameters

$applied array
Section name to boolean. Starts as array( 'general' => false ) and is flipped to true only when the payload actually contained a general array.
$payload array
The full decoded document as uploaded, which is exactly what happenboard_settings_backup_export produced on the other site. Your section may be absent.

Return value

array The per-section applied map, with your keys added. It is returned to the REST client as the applied object, which is what the admin UI reports to the operator.

Example

add_filter(
    'happenboard_settings_backup_import',
    static function ( array $applied, array $payload ): array {
        // The file may come from a site where your plugin was never
        // installed. Without this guard you would write an empty array
        // over the operator's live settings.
        if ( ! isset( $payload['my_plugin'] ) || ! is_array( $payload['my_plugin'] ) ) {
            $applied['my_plugin'] = false;

            return $applied;
        }

        // Patch, do not replace: keys absent from the file keep their
        // current value. This mirrors what HappenBoard does with its own
        // `general` section.
        $current = (array) get_option( 'my_plugin_settings', array() );
        update_option( 'my_plugin_settings', array_merge( $current, $payload['my_plugin'] ) );

        $applied['my_plugin'] = true;

        return $applied;
    },
    10,
    2
);

Notes

  • Two arguments, so add_filter( ..., 10, 2 ). Without it you only receive $applied and cannot read the payload at all.
  • The filter runs on every valid import, including files that never contained your section, so guard with isset( $payload['your_key'] ) and is_array(). Reading a missing key gives you null, and feeding that into update_option() overwrites the operator’s live settings with nothing, on a file that simply predates your plugin.
  • Returning something other than an array does more than lose your flag. import() falls back to array( 'general' => true ), so the UI then reports the general settings as restored even when the file had no general section at all. Always return the map.
  • HappenBoard restores its own general block as a partial patch: GeneralRepository::save() merges the incoming values into the current ones, so keys missing from the file keep their current value instead of reverting to a default. Mirror that unless you really mean to replace the whole option.
  • Only format and version are validated before your callback runs, and the version check only accepts 1 through SettingsBackup::FORMAT_VERSION. Nothing inside your section is sanitised on the way in, so treat $payload['your_key'] as untrusted user input. Pro does not listen here either.