Importer Contract
Jetonomy overviewFree + ProGet Jetonomy →
Jetonomy\Import\Importer — includes/import/class-importer.php — is the
abstract base every migration source extends. Free ships three:
BBPress_Importer, WPForo_Importer, Asgaros_Importer
(includes/import/class-{source}-importer.php).
Required methods (abstract)
Section titled “Required methods (abstract)”Every subclass implements:
get_source_name(): stringis_source_available(): boolget_source_stats(): arrayget_total_count(): intrun( array $options = [] ): arrayrun_batch( string $phase, int $offset, int $batch_size ): arrayrun_batch() is the batched-import driver used by the Import screen: it
processes rows for one $phase ('forums'|'topics'|'replies'|'profiles'|'recount',
plus any phase of your own) starting at $offset, and returns
['phase', 'offset', 'done', 'processed'] so the AJAX handler can resume
across requests. wp jetonomy import calls run() instead.
get_total_count() must count exactly the rows the batches report as
processed, the profiles phase included, or the progress bar runs past 100%.
A phase whose size is not known up front (bbPress’s members phase) reports
processed = 0.
Optional: is_source_active()
Section titled “Optional: is_source_active()”public function is_source_active(): bool // default trueis_source_available() asks “is there data to import”; this asks “is the
source plugin itself running”. Importers read the source tables directly, so
data left behind by a deactivated or deleted plugin is still importable. The
Import screen shows such a source as Data found, plugin not active instead
of Available. The bundled importers check class_exists( 'bbPress' ),
defined( 'WPFORO_VERSION' ) and class_exists( 'AsgarosForum' ).
Shared helpers on the base class
Section titled “Shared helpers on the base class”register_body_media()
Section titled “register_body_media()”register_body_media( string $body, string $path_prefix ): intRegisters every file a post body references, out of the source forum’s own
upload folder (e.g. wpforo/), into the WP media library — without
moving, copying, or rewriting the body. Returns the count of newly tracked
files. Use for inline <img>/<a> references left in imported HTML; use
link_attachment() below for the attachment box.
ensure_media_id()
Section titled “ensure_media_id()”ensure_media_id( int $known_media_id, string $file_url, string $file_name = '' ): intResolves a source attachment to a WP media ID, importing it if needed.
Reuses $known_media_id when the source already registered it. Otherwise
resolves $file_url to a real path inside uploads/ via
resolve_upload_path(), validates the file type (refuses anything WP
wouldn’t accept as an upload), and calls wp_insert_attachment(). Returns
0 and logs an error when the file can’t be recovered.
resolve_upload_path()
Section titled “resolve_upload_path()”resolve_upload_path( string $file_url ): stringThe single place any importer turns a source URL into a real path inside
uploads/. Matches on the URL path only (ignores scheme/host), because
real forum markup carries protocol-relative URLs (wpForo), stale http://,
or an old domain. Enforces containment with realpath() so a crafted
../../../wp-config.php path can never resolve — returns '' for anything
outside uploads/ or missing on disk.
link_attachment()
Section titled “link_attachment()”link_attachment( string $object_type, int $object_id, int $attachment_id, int $sort = 0 ): boolLinks a recovered media item to an imported post/reply via
Attachment::link(). Always available since attachments
moved to free in 1.8.0 — no Pro dependency, no filter round-trip.
sort_rows_parents_first()
Section titled “sort_rows_parents_first()”sort_rows_parents_first( array $rows, string $id_key, string $parent_key ): arrayReorders source forum rows so every parent is emitted before its children — a
breadth-first walk from the roots (parent = 0) outward. Forum hierarchy is
preserved by mapping each child’s parent_id to its already-imported parent,
which only works if the parent was created first; feeding the raw result set
straight in dropped any child forum whose parent happened to sort later. Rows
whose parent never resolves (orphans or cycles) are appended at the end rather
than lost.
All three bundled importers run their forum rows through this helper before
creating spaces, so the tree survives the migration and parent_id is carried
onto the new space.
get_errors()
Section titled “get_errors()”get_errors(): arrayReturns this batch’s non-fatal errors as
array<int, array{type: string, id: mixed, message: string}> (e.g. an
attachment whose file couldn’t be recovered). The AJAX handler reads this
after every batch and accumulates it into the jetonomy_import_errors option,
then the import report renders the count plus a sample of up to 50 rows
(includes/admin/ajax/class-import-handler.php,
includes/admin/views/import.php) — so a skipped file is reported to the
customer instead of vanishing behind a silent “Import complete!”.
Re-runs: the import map
Section titled “Re-runs: the import map”Every bundled importer records what it created in jt_import_map
(Jetonomy\Models\Import_Map, includes/models/class-import-map.php): one row
per source forum, topic, reply and import category, keyed by importer slug,
object type and source id. That record is the only “already imported?” check,
so a re-run skips what exists and imports only what is new. The base class
wraps it in four helpers.
map_source()
Section titled “map_source()”protected function map_source(): string // default ''The slug your rows are recorded under (the bundled importers return bbpress,
wpforo, asgaros). Override it to opt in. The default '' opts out: the
helpers below do nothing and find_imported() returns nothing, so an importer
written before 2.0.1 behaves exactly as it did.
find_imported()
Section titled “find_imported()”protected function find_imported( string $type, array $rows ): arrayWhich rows of this batch an earlier run already imported. $type is 'space',
'post' or 'reply'. $rows is source_id => fingerprint; the return value
is source_id => existing Jetonomy id, and rows not imported yet are absent.
It runs one map lookup per batch, not per row. For source ids the map does not know, it falls back to a legacy fingerprint match for content imported before the map existed, and writes each match back to the map so it is exact from then on:
$type |
Fingerprint | Matches |
|---|---|---|
space |
[ 'slug' => ... ] |
A space with that slug inside a category whose slug starts with imported-{map_source} |
post |
[ 'parent' => space_id, 'author' => user_id, 'created' => 'Y-m-d H:i:s' ] |
A topic in that space by that author at that second |
reply |
[ 'parent' => post_id, 'author' => user_id, 'created' => 'Y-m-d H:i:s' ] |
A reply on that topic by that author at that second |
parent is the Jetonomy id the row would be created under. A row already
claimed by a map entry is never matched again, and two identical fingerprints
pair off in id order. A legacy row whose source date was empty cannot be
recognised. If you have no legacy content to recognise, pass an empty array as
each fingerprint. A mapping whose Jetonomy row has since been deleted is
dropped, so that row imports again.
remember()
Section titled “remember()”protected function remember( string $type, $source_id, int $object_id ): voidRecord that a source row produced a Jetonomy row. Call it right after each
successful create. No-op during a dry run or when map_source() is ''.
import_category()
Section titled “import_category()”protected function import_category( string $key, string $slug_prefix, array $args ): intThe category your spaces are filed under, created only when first needed.
Resolution order: the category this importer recorded under $key, then the
oldest category whose slug starts with $slug_prefix and holds a space (a
pre-map import), then a new Category::create( $args ) with slug
{$slug_prefix}-{time}. Call it lazily, when a forum actually has to be
created, so a re-run with nothing new creates no empty category. Use an
imported-{map_source} prefix so the legacy space match above can find it.
Resolving parents across batches: load_mapped()
Section titled “Resolving parents across batches: load_mapped()”protected function load_mapped( string $key, string $type, array $ids ): voidEvery batch runs in a fresh request with an empty $this->id_map. Nothing is
carried between batches (releases before 2.0.1 kept the whole map in the
jetonomy_import_id_map option, rewritten every batch - about 10 MB at 500k
replies). Before a row loop, load the parents this batch’s rows point at from
jt_import_map: $key is the id map type ('forum', 'topic', …), $type
the map’s object type ('space', 'post', 'reply', 'category'), and $ids
is local id => map source id. One indexed query per call; ids already in
memory are skipped, so it is harmless in run().
$forums = array_unique( array_map( 'intval', array_column( $topics, 'forum_id' ) ) );$this->load_mapped( 'forum', 'space', array_combine( $forums, $forums ) );Counting: $already, skip_orphan(), get_tally(), describe_tally()
Section titled “Counting: $already, skip_orphan(), get_tally(), describe_tally()”Increment $this->already (not $this->skipped) for each row
find_imported() recognised. $skipped means “not imported”;
$already means “was already there”. When a row is left out because its
parent was not imported, call $this->skip_orphan( 'topic' | 'reply' ): it
counts as skipped and records the reason.
get_tally() returns
[ 'imported' => int, 'already' => int, 'rethreaded' => int, 'orphans' => [ type => int ] ]
for the batch driver, which accumulates it; results() carries the same keys
beside skipped and errors. Importer::describe_tally( $tally ) turns
either into the translated sentences the Import screen, Past imports and
wp jetonomy import all print.
Slugs: clean_slug()
Section titled “Slugs: clean_slug()”protected static function clean_slug( string $slug ): stringDrops emoji and symbols (which arrive percent-encoded from a WordPress-style slug) and keeps letters and digits of any script. Use it for new rows only; legacy recognition matches the slug an older release wrote verbatim.
Example
Section titled “Example”A topics batch for a third-party importer:
class My_Forum_Importer extends \Jetonomy\Import\Importer {
protected function map_source(): string { return 'my-forum'; }
private function import_topic_rows( array $topics ): void { $fingerprints = []; foreach ( $topics as $t ) { $fingerprints[ $t->id ] = [ 'parent' => (int) $this->get_mapped_id( 'forum', $t->forum_id ), 'author' => (int) $t->user_id, 'created' => $t->created_at, ]; } $existing = $this->find_imported( 'post', $fingerprints );
foreach ( $topics as $t ) { if ( isset( $existing[ $t->id ] ) ) { // Keep it mapped so new replies under it still import. $this->map_id( 'topic', $t->id, $existing[ $t->id ] ); ++$this->already; continue; }
$post_id = \Jetonomy\Models\Post::create( [ /* ... */ ] ); if ( $post_id && ! is_wp_error( $post_id ) ) { $this->map_id( 'topic', $t->id, (int) $post_id ); $this->remember( 'post', $t->id, (int) $post_id ); ++$this->imported; } } }}Forums follow the same shape with find_imported( 'space', ... ) and a
[ 'slug' => ... ] fingerprint; call import_category() only when a forum is
not already mapped.
Per-batch time budget
Section titled “Per-batch time budget”start_budget(): void // call at the top of run_batch()budget_spent(): bool // check inside the row loop; stop at a row boundary when trueA single 4000×3000 forum photo can cost ~0.9s to register (thumbnail
regeneration), so an image-heavy forum can blow past
max_execution_time inside one batch. start_budget() sets a deadline
(default 15s, filterable):
/** * Seconds a single import batch may spend before it stops at a row boundary. * * @param float $seconds Default 15. */apply_filters( 'jetonomy_import_batch_seconds', 15.0 );Check budget_spent() between rows and return early (persisting progress)
once it trips. A batch that dies mid-request is worse than a slow one: the
driver resumes at the offset it last saw, so the rows the killed batch
created are recognised as already imported only if each one was
remember()ed right after it was created.
Adding a new source importer
Section titled “Adding a new source importer”- Create
includes/import/class-{source}-importer.php, extendingJetonomy\Import\Importer, implementing the abstract methods above. - Use
map_id()/get_mapped_id()to track old-ID → new-ID within a request, andload_mapped()at the top of each batch to bring in parents an earlier batch created. If the source nests forums, run the forum rows throughsort_rows_parents_first()before creating spaces so a parent is always mapped before its children reference it. - Override
map_source(), askfind_imported()before creating each batch, and callremember()after each create, so re-runs are safe (see Re-runs: the import map). File spaces underimport_category(). - Call
start_budget()at the top ofrun_batch(), andbudget_spent()inside the per-row loop. - For inline body media, call
register_body_media( $body, 'your-plugin-folder' ). For an explicit attachment box, resolve viaensure_media_id()thenlink_attachment(). - Register the importer in
Jetonomy\Import\Import_Manager::init(), or from outside the plugin via thejetonomy_importersfilter:
add_filter( 'jetonomy_importers', function ( array $importers ) { $importers['my-forum'] = new My_Forum_Importer(); return $importers;} );
