feat: FocalPoint (Shack Locations) migration import

Add one-click import from installed FocalPoint/Shack Locations component:
- Reads #__focalpoint_locations table directly via Joomla DB layer
- Parses customfieldsdata JSON for email, website, hours, phone
- Maps FocalPoint schema to MokoSuiteStoreLocator fields
- Copies coordinates (DECIMAL 10,6 → 10,8), published state, ordering
- Combines description + fulldescription into single description field
- Import button on admin Import view with CSRF + ACL protection
- Graceful handling: checks table exists, reports per-row errors

Field mapping:
  FocalPoint.title → title
  FocalPoint.address → address (single field, no city/state split)
  FocalPoint.latitude/longitude → latitude/longitude
  FocalPoint.phone → phone
  FocalPoint.customfieldsdata.email → email
  FocalPoint.customfieldsdata.website → website
  FocalPoint.customfieldsdata.hours → hours
  FocalPoint.state → published

Authored-by: Moko Consulting
This commit is contained in:
Jonathan Miller
2026-06-23 13:04:09 -05:00
committed by Jonathan Miller
parent 6e20567240
commit cf495cd8ce
4 changed files with 221 additions and 0 deletions
@@ -59,3 +59,8 @@ COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE="The uploaded file is not a valid CSV."
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_ROWS="The CSV file contains no data rows."
COM_MOKOJOOMSTORELOCATOR_IMPORT_MISSING_TITLE="Row %d: Title is required."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE="Import from FocalPoint"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC="Migrate locations from an installed FocalPoint (Shack Locations) component. Coordinates, custom fields (email, website, hours), and metadata are mapped automatically."
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON="Import FocalPoint Locations"
COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS="%d location(s) imported from FocalPoint."
@@ -84,4 +84,45 @@ class ImportController extends BaseController
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* @return void
*
* @since 1.1.0
*/
public function focalpoint(): void
{
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokosuitestorelocator'))
{
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
return;
}
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Model\ImportModel $model */
$model = $this->getModel('Import', 'Administrator');
$result = $model->importFromFocalPoint();
if ($result['imported'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_SUCCESS', $result['imported']));
}
if ($result['skipped'] > 0)
{
$this->setMessage(Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_SKIPPED', $result['skipped']), 'warning');
}
foreach ($result['errors'] as $error)
{
$this->setMessage($error, 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=locations', false));
}
}
@@ -211,4 +211,164 @@ class ImportModel extends BaseDatabaseModel
return $data;
}
/**
* Import locations from an installed FocalPoint (Shack Locations) component.
*
* Reads directly from #__focalpoint_locations and #__focalpoint_locationtypes
* tables and inserts into #__mokosuitestorelocator_locations using the standard
* bind()->check()->store() flow.
*
* @return array ['imported' => int, 'skipped' => int, 'errors' => string[]]
*
* @since 1.1.0
*/
public function importFromFocalPoint(): array
{
$result = ['imported' => 0, 'skipped' => 0, 'errors' => []];
$db = $this->getDatabase();
// Check if FocalPoint tables exist
$tables = $db->getTableList();
$prefix = $db->getPrefix();
$fpTable = $prefix . 'focalpoint_locations';
if (!\in_array($fpTable, $tables))
{
$result['errors'][] = 'FocalPoint is not installed — table #__focalpoint_locations not found.';
return $result;
}
// Load all FocalPoint locations
$query = $db->getQuery(true)
->select('a.*')
->from($db->quoteName('#__focalpoint_locations', 'a'))
->order($db->quoteName('a.id') . ' ASC');
$db->setQuery($query);
$fpLocations = $db->loadObjectList();
if (empty($fpLocations))
{
$result['errors'][] = 'No locations found in FocalPoint.';
return $result;
}
// Load location type names for category context
$typeQuery = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__focalpoint_locationtypes'));
$db->setQuery($typeQuery);
$typeNames = $db->loadObjectList('id');
/** @var \Moko\Component\MokoSuiteStoreLocator\Administrator\Table\LocationTable $table */
$table = $this->getMVCFactory()->createTable('Location', 'Administrator');
foreach ($fpLocations as $fpLoc)
{
$table->reset();
$table->id = 0;
// Parse custom fields JSON for email, website, phone, hours
$customData = $this->parseFocalPointCustomFields($fpLoc->customfieldsdata ?? '');
// Map FocalPoint fields to our schema
$data = [
'title' => $fpLoc->title,
'alias' => $fpLoc->alias ?: '',
'description' => trim(($fpLoc->description ?? '') . "\n" . ($fpLoc->fulldescription ?? '')),
'address' => $fpLoc->address ?? '',
'city' => $customData['city'] ?? '',
'state' => $customData['state'] ?? '',
'postcode' => $customData['postcode'] ?? $customData['zip'] ?? '',
'country' => $customData['country'] ?? '',
'latitude' => $fpLoc->latitude != 0 ? $fpLoc->latitude : null,
'longitude' => $fpLoc->longitude != 0 ? $fpLoc->longitude : null,
'phone' => $customData['phone'] ?? $fpLoc->phone ?? '',
'email' => $customData['email'] ?? '',
'website' => $customData['website'] ?? $customData['url'] ?? '',
'hours' => $customData['hours'] ?? $customData['business_hours'] ?? '',
'image' => $fpLoc->image ?? '',
'published' => (int) ($fpLoc->state ?? 0),
'ordering' => (int) ($fpLoc->ordering ?? 0),
'params' => '{}',
];
if (!$table->bind($data))
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->check())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
if (!$table->store())
{
$result['errors'][] = "FocalPoint #{$fpLoc->id} ({$fpLoc->title}): " . $table->getError();
$result['skipped']++;
continue;
}
$result['imported']++;
}
return $result;
}
/**
* Parse FocalPoint customfieldsdata JSON into a flat key-value array.
*
* FocalPoint stores custom field data as JSON. The structure varies by version:
* - Simple: {"fieldname": "value", ...}
* - Nested: {"fieldname": {"value": "...", "label": "..."}, ...}
*
* We normalize to lowercase keys with string values for easy field matching.
*
* @param string $json The customfieldsdata JSON string.
*
* @return array Flat associative array of field_name => value.
*
* @since 1.1.0
*/
private function parseFocalPointCustomFields(string $json): array
{
if (empty($json) || $json === '{}')
{
return [];
}
$decoded = json_decode($json, true);
if (!\is_array($decoded))
{
return [];
}
$fields = [];
foreach ($decoded as $key => $value)
{
$normalizedKey = strtolower(trim(str_replace([' ', '-'], '_', $key)));
if (\is_array($value))
{
// Nested format: {"value": "...", "label": "..."}
$fields[$normalizedKey] = trim((string) ($value['value'] ?? $value[0] ?? ''));
}
else
{
$fields[$normalizedKey] = trim((string) $value);
}
}
return $fields;
}
}
@@ -60,6 +60,21 @@ use Joomla\CMS\Session\Session;
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<h4><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_TITLE'); ?></h4>
<p class="text-muted"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_DESC'); ?></p>
<form action="<?php echo Route::_('index.php?option=com_mokosuitestorelocator&task=import.focalpoint'); ?>"
method="post">
<button type="submit" class="btn btn-outline-primary w-100">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FP_BUTTON'); ?>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h4><?php echo Text::_('JHELP'); ?></h4>