From cf495cd8ceff39dd88684cb489834634f848a34e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 13:04:09 -0500 Subject: [PATCH] feat: FocalPoint (Shack Locations) migration import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../en-GB/com_mokosuitestorelocator.ini | 5 + .../admin/src/Controller/ImportController.php | 41 +++++ .../admin/src/Model/ImportModel.php | 160 ++++++++++++++++++ .../admin/tmpl/import/default.php | 15 ++ 4 files changed, 221 insertions(+) diff --git a/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini index 90a0380..3747e74 100644 --- a/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini +++ b/source/packages/com_mokosuitestorelocator/admin/language/en-GB/com_mokosuitestorelocator.ini @@ -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." diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php b/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php index 58585f0..148841f 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Controller/ImportController.php @@ -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)); + } } diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php index e859cb1..cb2177e 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php @@ -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; + } } diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/import/default.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/import/default.php index 9a0c282..b311f27 100644 --- a/source/packages/com_mokosuitestorelocator/admin/tmpl/import/default.php +++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/import/default.php @@ -60,6 +60,21 @@ use Joomla\CMS\Session\Session;
+
+
+

+

+
+ + +
+
+
+