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
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
-
$adaptersarray - 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 whoseid()isthe-events-calendar,events-manager,sugar-calendar,eventkoiorameliasilently replaces that built-in adapter, with no warning. That is also how you deliberately override one. - Entries that are not
MigratorAdapterinstances 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 nois_array()guard. Registry::all()is also called byget()anddetected(), so the filter fires several times per request and your adapter is instantiated each time. Keep the constructor empty and do the database work insummary()andmigrate().detect()is a gate, not a hint.POST /happenboard/v1/migration/{source}returns a 400happenboard_source_not_detectedand 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_eventsandhappenboard_migration_event_importedyourself, with the same argument order.
happenboard_migration_before_events
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_idstring - The adapter id. Always
the-events-calendartoday, since no other built-in adapter fires this action. Test it anyway so your callback stays correct if another source starts firing it. -
$drybool - True when the operator asked for a preview (
dry_runin the REST body,--dry-runon 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, 2toadd_action(). With the default accepted-args of 1, a callback typedfunction ( string $source_id, bool $dry )throws anArgumentCountErrorand 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$dryis 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$dryis 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
includelist leaves outevents, 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
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_idint - The newly created
hboard_eventpost ID. Always a real ID: theWP_Errorbranch ofwp_insert_post()continues the loop before this action. -
$source_idint - The source post ID, a The Events Calendar
tribe_eventspost. This is where the untouched original meta still lives. -
$adapter_idstring - The adapter id,
the-events-calendar. Check it before doing any work so your callback stays inert for other sources. -
$resultResult - The live
HappenBoard\Service\Migration\Resultaccumulator for this batch. Itscount_created(),count_skipped(),count_failed()andadd_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 fromhappenboard_migration_before_eventsinstead. - 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. $resultis an object, so your mutations survive into the response.add_error()puts a row in the migration UI’s error list, andcount_created()with a resource name the core adapter never uses creates a brand new counter on the fly (Resultinitialises unknown buckets on first access).- It only fires for events that were actually inserted. Events already carrying
_happenboard_migrated_fromhit 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 owncreatedcounter is bumped, and only from the The Events Calendar adapter.
happenboard_settings_backup_export
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
-
$payloadarray - The base document, with the keys
format(alwayshappenboard.settings),version(currently 1),plugin,site,exportedAtandgeneral.
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,exportedAtandgeneralalone. Changingformatmakes the file unimportable:import()rejects anything whose format is nothappenboard.settings. - Always return the array you were given.
export()ends withis_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:
generalisGeneralRepository::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 maskedpublic_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
problock, but nothing in HappenBoard Pro hooks either backup filter, so Pro settings are absent from the export. Do not rely on aprokey 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
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
-
$appliedarray - Section name to boolean. Starts as
array( 'general' => false )and is flipped to true only when the payload actually contained ageneralarray. -
$payloadarray - The full decoded document as uploaded, which is exactly what
happenboard_settings_backup_exportproduced 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$appliedand 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'] )andis_array(). Reading a missing key gives you null, and feeding that intoupdate_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 toarray( 'general' => true ), so the UI then reports the general settings as restored even when the file had nogeneralsection at all. Always return the map. - HappenBoard restores its own
generalblock 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
formatandversionare validated before your callback runs, and the version check only accepts 1 throughSettingsBackup::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.