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:
+5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user