Developers

Building an Import Type Add-on: the Term Descriptions Case Study

Published

This guide walks through a complete, real add-on that gives Super Speedy Imports a new kind of import: Term Descriptions, which imports one CSV row per taxonomy term and updates term descriptions (and, optionally, names) instead of posts. Every code sample comes from that add-on. Read it alongside Creating Add-ons and Creating Custom Templates, which document each hook on its own. The add-on itself is Term Descriptions for Super Speedy Imports.

Available since Super Speedy Imports 3.04.

Table of Contents

  1. What the add-on does
  2. Template or add-on? Both
  3. File layout
  4. Bootstrap: registration, version gate and editions
  5. The template class
  6. Fields and the mapping screen
  7. Identity: how a row finds its term
  8. Stages
  9. Import modes and presets
  10. Timeout recovery
  11. Auto-configuration of a recognised CSV
  12. After-import tasks
  13. SQL rules the add-on follows
  14. Testing an import type
  15. Checklist for your own import type

What the add-on does

The core plugin’s Data tab finds taxonomy terms with missing or short descriptions and downloads them as a CSV. The Term Descriptions add-on adds the checks for duplicated and repetitive descriptions to the same view, through the Data tab term hooks (see Adding Term Checks to the Data Tab in Creating Add-ons), and imports the CSV back once the descriptions are written:

  1. Data tab: choose Taxonomy terms, a taxonomy such as product_tag, and the “short description” check.
  2. Download the findings.
  3. Edit the descriptions in a spreadsheet.
  4. + New Import, upload the file. The upload receipt recognises term data and chooses Term Descriptions; the import configures itself.
  5. Run it. Only the edited descriptions change.

Without the add-on, core recognises a term file, refuses to import it as posts, and links to the add-on.

Everything that makes an import an import comes from core: CSV loading and parallel segments, saved configurations, the history, the three runners (browser, WP-CLI and scheduled), stage timeouts and recovery. The add-on supplies only what is different about terms.

Template or add-on? Both

A template defines what a row means and which stages write it. An add-on is the plugin that ships the template and adjusts core behaviour through filters. An import type is therefore a template shipped in an add-on:

ConcernWhere it lives
Fields, identity, stages, optionsthe template class (SuperSpeedyImports_ImportTemplate subclass)
Registration, version gate, presets, recovery list, auto-config, after-import tasksthe add-on’s main file, through filters
The mapping section on the configuration screena JavaScript file beside the template class

File layout

ssi-addon-term-descriptions/
├── ssi-addon-term-descriptions.php   header, updates, version gate, registration, filters
├── includes/
│   ├── class-ssitd-template.php      the template class
│   ├── class-ssitd-template.js       the "Term fields" section (loaded automatically)
│   └── auto-config.php               superspeedyimports_pre_auto_config callback
├── super-speedy-settings/            shared updates and licence library
└── readme.txt

Core loads a JavaScript file that sits beside the template class and shares its base name, whenever an import of that template is opened.

Bootstrap: registration, version gate and editions

Super Speedy Imports fires superspeedyimports_register_templates while its own main file loads. Add-ons load in alphabetical order, usually before it, so the callback is added at file load, not on plugins_loaded:

define('SSITD_MIN_SSI', '3.04');

add_action('superspeedyimports_register_templates', 'ssitd_register');

function ssitd_register() {
    if (!defined('SUPERSPEEDYIMPORTS_VERSION') || version_compare(SUPERSPEEDYIMPORTS_VERSION, SSITD_MIN_SSI, '<')) {
        $GLOBALS['ssitd_gate'] = 'old';
        return;
    }
    if (!function_exists('superspeedyimports_is_free') || superspeedyimports_is_free()) {
        $GLOBALS['ssitd_gate'] = 'free';
        return;
    }
    $GLOBALS['ssitd_gate'] = '';
    require_once SSITD_DIR . 'includes/class-ssitd-template.php';
    require_once SSITD_DIR . 'includes/auto-config.php';
    new SSITD_TermDescriptionsTemplate();

    add_filter('superspeedyimports_pre_auto_config', 'ssitd_pre_auto_config', 10, 4);
    add_filter('superspeedyimports_stage_presets', 'ssitd_stage_presets', 10, 2);
    add_filter('superspeedyimports_recoverable_stages', 'ssitd_recoverable_stages');
    add_filter('superspeedyimports_after_import_tasks', 'ssitd_after_import_tasks', 10, 2);
}

Three things to copy:

  • Gate on the core version that carries the hooks you use, and register nothing below it. An admin notice explains why (the add-on reads $GLOBALS['ssitd_gate'] in admin_notices).
  • Register updates outside the gate. A customer with an old core still receives the add-on update that tells them what to do.
  • Instantiate the template once; its constructor registers it through addTemplateInfo().

The template class

class SSITD_TermDescriptionsTemplate extends SuperSpeedyImports_ImportTemplate {

    /** Not a post import. Core reads post_type in many places; '' means "no post type". */
    public $post_type = '';

    protected function addTemplateInfo() {
        self::addTemplate(array(
            'name'        => 'Term Descriptions',
            'class'       => 'SSITD_TermDescriptionsTemplate',
            'description' => 'Update the descriptions of categories, tags and other taxonomy terms from a term CSV',
            'icon'        => array('at' => 'at-list-bold', 'dash' => 'tag'),
            'record_kind' => 'term',
        ));
    }
}
  • It extends the abstract SuperSpeedyImports_ImportTemplate, not SuperSpeedyImports_PostTemplate, so it inherits none of the post stages, image stages or delete handling.
  • icon gives the import its own icon in the Imports list: an Atlas icon class (at) with a Dashicons fallback (dash).
  • record_kind => 'term' tells core what the rows are. Core uses it to recognise the template as the home for term files, and to hide post-only controls for its imports.

Fields and the mapping screen

Fields are declared in defineMappings() with a section of the template’s own:

'term_description' => array(
    'description' => 'Description',
    'tooltip'     => 'Written as-is, HTML included. An empty cell leaves the stored description unchanged.',
    'section'     => 'ssitd_terms',
    'data_type'   => 'LONGTEXT',
    'dest_hint'   => 'term_taxonomy.description',
),

The template hides every core mapping block, so only its own section appears:

public function getMappingSections() {
    return array();
}

and its JavaScript renders the section when core asks for it:

jQuery(document).on('ssi:renderSection', function (event) {
    if (event.sectionName !== 'ssitd_terms') {
        return;
    }
    var section = jQuery('<div>', { 'class': 'ssi-section ssitd-terms-section ssi-template-section' });
    // ... header and help text ...
    (event.postFields || []).forEach(function (key) {
        var definition = event.importData.defaults[key];
        var value = event.templateMappings && Object.prototype.hasOwnProperty.call(event.templateMappings, key)
            ? event.templateMappings[key] : definition;
        generateStandardField(content, key, value, event.csvHeaders, definition, false);
    });
});

Core groups a template’s fields under its own section heading and hides the flat copy of them, so the section appears once. For an import whose record_kind is term, core also adapts its own screens: the “View” links open each taxonomy’s term list (one per taxonomy in the file), and Preview, “Check existing data” and the delete-imported-items controls are not offered.

Rows made with generateStandardField() inside a .ssi-template-section save at the top level of template_mappings, so load, save and reload need no code of their own. The completion screen reads the item labels and the run counter from:

public function getItemLabels() {
    return array('singular' => 'term', 'plural' => 'terms', 'created_metric' => 'terms_created', 'updated_metric' => 'term_descriptions_updated');
}

Import options are ordinary additional-options fields (getAdditionalOptionsFields()), here Create missing terms and Update term names, both off by default, plus the import mode and the blank-identifier policy. Saved values reach the stages as $this->additional_options.

Identity: how a row finds its term

Every import matches rows to existing records with an identity. The template offers two presets and turns off the custom builder:

public function getUniqueIdentifierConfig() {
    return array(
        'presets' => array(
            array('key' => 'taxonomy_slug', 'label' => 'Taxonomy + slug',
                  'components' => array('term:taxonomy', 'term:term_slug'), 'default' => true),
            array('key' => 'term_id', 'label' => 'Taxonomy + Term ID (a Data tab download from this site)',
                  'components' => array('term:term_id'), 'source_column' => true,
                  'source_label' => 'Term ID CSV column'),
        ),
        'available_postmeta' => array(),
        'custom' => false,
    );
}
  • A token is <type>:<field key> and names one mapped field (term:taxonomy, term:term_slug). The review step before a run checks that every matching field is mapped by looking each key up among the template’s fields, so an invented compound token such as term:taxonomy_slug blocks the run. The one exception is a source_column preset’s token (term:term_id), whose value comes from the chosen CSV column.
  • source_column => true shows a CSV-column picker for the preset, for identities that come from a column rather than a mapped field.
  • The taxonomy is part of every match, so a term is never matched in the wrong taxonomy, and a Term ID from another site is never used: auto-configuration only offers Term ID matching for a file downloaded from this site.

mapData() runs per row after the cells are loaded and builds the identity value core stores in _ssi_unique_item_id. Core’s duplicate and blank-identifier guards then work unchanged: two rows for one term abort the import, and blank identities follow the chosen policy.

Stages

The template keeps core’s load-csv and adds its own stages:

protected function registerCoreStages() {
    if ($this->auth_manager === null) {
        return; // configuration screens register no stages
    }
    $this->registerStage('load-csv', 10, array($this, 'stage_load_csv'), 'Load CSV Data');
}

protected function registerTemplateStages() {
    $this->registerStage('match-terms', 30, array($this, 'stage_match_terms'), 'Match existing terms');
    if (!empty($this->additional_options['ssitd_create_missing_terms'])) {
        $this->registerStage('insert-terms', 35, array($this, 'stage_insert_terms'), 'Create missing terms');
    }
    $this->registerStage('update-term-descriptions', 40, array($this, 'stage_update_term_descriptions'), 'Update term descriptions');
}
PriorityStageWork
10load-csvcore, unchanged: the CSV into the batch table, in parallel segments
30match-termsresolves each row to one term with a joined UPDATE; aborts on an ambiguous slug; lists unmatched and rejected rows as issues
35insert-termsonly with Create missing terms: creates each missing term with wp_insert_term(), parents first
40update-term-descriptionswrites changed descriptions (and names, by option) in joined UPDATEs, then clears the term cache

Stages run on all three runners with no runner code in the add-on: registering them is enough. Two details make a stage safe to run at scale:

  • Compare what is stored before writing. The update marks rows whose description differs (compared as binary, so a case-only edit counts), writes only those, and counts the marks. A second run changes nothing and reports zero.
  • Keep empty cells from overwriting. An empty or whitespace-only description cell leaves the stored description as it is.

Import modes and presets

When the import mode is anything other than full, core keeps only the stages listed for that mode. A template with its own stages must declare them, or a run in that mode loads the CSV and changes nothing:

function ssitd_stage_presets($presets, $template) {
    if (!($template instanceof SSITD_TermDescriptionsTemplate)) {
        return $presets;
    }
    $presets['only_update'] = array('source-fetch', 'xml-to-csv', 'load-csv', 'match-terms', 'update-term-descriptions');
    return $presets;
}

Always check the template before changing presets: the filter runs for every import. The full mode runs every registered stage without consulting the presets, so there is nothing to declare for it.

Timeout recovery

A browser request can time out while its SQL keeps running on the server, and a PHP process can die mid-stage. Core recovers a stage only when it is listed as safe to run again:

function ssitd_recoverable_stages($stages) {
    $stages[] = 'match-terms';
    $stages[] = 'insert-terms';
    $stages[] = 'update-term-descriptions';
    return $stages;
}

List a stage only when running it again from the start is harmless. match-terms resets its columns first; the update compares before it writes; insert-terms records each row as “creating” before calling wp_insert_term() and adopts a term created by a process that died before recording it, so a retry never creates a duplicate.

The recovery probe recognises a stage’s SQL by a fingerprint comment. Run every stage statement through superspeedyimports_query(), superspeedyimports_get_results() or superspeedyimports_get_var(), which add it.

Auto-configuration of a recognised CSV

superspeedyimports_pre_auto_config runs at the start of automatic configuration for every intake: + New Import, wp ssi create, the Abilities API and the WP All Import migration. Return a complete configuration for your template, or pass the value through:

function ssitd_pre_auto_config($pre, $csv_path, $template_class, $field_separator) {
    if ($template_class !== 'SSITD_TermDescriptionsTemplate' || !is_readable($csv_path)) {
        return $pre;
    }
    $kind = superspeedyimports_term_data_csv_kind($csv_path, $field_separator); // 'export', 'list' or ''
    $same_site = $kind === 'export' && superspeedyimports_is_same_site_term_data_csv($csv_path, $field_separator);
    // ... map columns by exact Data tab names, then by aliases ...
    return array(
        'template_mappings'  => $mappings + array('taxonomies' => array(), 'post_meta' => array(), 'media' => array()),
        'functions'          => null,
        'additional_options' => array(
            'unique_id_type'       => 'composite',
            'unique_id_components' => $same_site ? array('term:term_id') : array('term:taxonomy', 'term:term_slug'),
            'unique_id_source'     => $same_site ? 'terms.term_id' : '',
            'import_mode'          => 'full',
            // ... the template's own options, off by default ...
        ),
    );
}

Columns the template does not import go in the auto-map report as unmapped, so the user can see an edited column that is not written.

After-import tasks

Core runs after-import tasks such as WooCommerce lookup rebuilds after every import. A term import changes no products, so the add-on removes them for its own imports:

function ssitd_after_import_tasks($tasks, $template = null) {
    return ($template instanceof SSITD_TermDescriptionsTemplate) ? array() : $tasks;
}

SQL rules the add-on follows

  • Every statement is built with $wpdb->prepare(): %i for table and column names, placeholders for values, and implode(',', array_fill(0, count($ids), '%d')) for lists. Nothing is interpolated into SQL.
  • One bulk statement per job, not loops of small queries. The only per-row work is term creation, which uses wp_insert_term() so WordPress keeps slugs unique per taxonomy and runs its term hooks.
  • The batch table carries the template’s working columns ($this->extra_fields in the constructor), so a stage never needs a temporary table of its own.

Testing an import type

The add-on’s own tests drive the real product, not mocks:

  • The user’s journey in a browser: Data tab findings, download, edit, + New Import, run, then a before-and-after fingerprint of wp_terms, wp_term_taxonomy, wp_termmeta, wp_posts, wp_postmeta and wp_term_relationships. Exactly the edited descriptions change, and a second run changes nothing.
  • The same import on every runner: browser, wp ssi <id> and the scheduled runner give identical results.
  • Recovery: a forced browser timeout and a PHP death inside each stage, then a check that the run completes with correct counts and no duplicated terms.
  • Options and refusals: each option on and off, an ambiguous slug, a taxonomy that cannot be imported, and a file from another site.

Checklist for your own import type

  1. Extend SuperSpeedyImports_ImportTemplate; set $post_type = '' when the rows are not posts.
  2. Register with icon and record_kind.
  3. Declare fields in your own section; return array() from getMappingSections() and render the section from a JavaScript file beside the class.
  4. Offer identity presets named after their fields; set custom => false if a custom identity makes no sense.
  5. Keep load-csv; register your stages; make each one safe to run twice.
  6. Add your stages to superspeedyimports_stage_presets for every mode you support, and to superspeedyimports_recoverable_stages.
  7. Auto-configure recognised files with superspeedyimports_pre_auto_config.
  8. Remove after-import tasks that do not apply.
  9. Gate on the core version and edition; register updates outside the gate.
  10. Test the journey, all three runners and recovery on a real site.

Further reading

Every add-on hook, including the Data tab term checks.
The template class in depth.
Where the term findings this add-on imports come from.

Written for Super Speedy Imports 3.63.0.

Comments

▲
▼
1/1