diff --git a/CHANGELOG.md b/CHANGELOG.md index a846143..138c757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,22 @@ # Linkit Changelog > One link field to rule them all, built for [Craft 5](http://craftcms.com) +## 5.0.1 - 2025-11-06 + +### Added +- Conversion controller for native link field + ## 5.0.0 - 2024-05-20 ### Added - Craft 5 compatibility - Allow self realtions on element link fields +## 4.0.4.2 - 2024-05-17 + +### Changed +- Allow self realtions on element link fields + ## 4.0.4.1 - 2022-10-24 ### Fixed diff --git a/composer.json b/composer.json index 8a2a6da..e5c07b4 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "presseddigital/linkit", "description": "One link field to rule them all.", "type": "craft-plugin", - "version": "5.0.0", + "version": "5.0.1", "keywords": [ "craftcms", "link it", diff --git a/src/console/controllers/ConvertController.php b/src/console/controllers/ConvertController.php new file mode 100644 index 0000000..bd19301 --- /dev/null +++ b/src/console/controllers/ConvertController.php @@ -0,0 +1,198 @@ +stdout("Convert Linkit to native Link field.\n"); + + // Get all LinkIt fields + $fields = (new Query()) + ->from('{{%fields}}') + ->where(['type' => LinkitField::class]) + ->all(); + + // Loop through each field and migrate settings in this pass + foreach ($fields as $field) { + $this->stdout("\nPreparing to migrate field '{$field['handle']}' ({$field['uid']}) settings.\n"); + $linkItSettings = json_decode($field['settings'], true); + $settings = $this->convertSettings($linkItSettings); + + // Update the field type and settings directly in the database + Db::update( + '{{%fields}}', + [ + 'type' => Link::class, + 'settings' => json_encode($settings, JSON_UNESCAPED_SLASHES) + ], + ['uid' => $field['uid']] + ); + + $this->stdout(" > Field {$field['handle']} successfully updated.\n"); + + // Rebuild project config for this field + $projectConfig = Craft::$app->getProjectConfig(); + $fieldsService = Craft::$app->getFields(); + + // Refresh fields cache so we get the updated field + $fieldsService->refreshFields(); + + // Get the updated field object + $updatedField = $fieldsService->getFieldByUid($field['uid']); + + if ($updatedField) { + // Rebuild the project config for this field + $projectConfig->set( + "fields.{$field['uid']}", + $fieldsService->createFieldConfig($updatedField) + ); + + $this->stdout(" > Project config rebuilt for field {$field['handle']}.\n"); + } + } + $this->stdout("\nConversion of LinkIt fields to Link complete.\n", Console::FG_GREEN, Console::BOLD ); + $this->stdout("Commit your project config changes, and run `craft up` on other environments for the changes to take effect.\n"); + $this->stdout("Run 'craft linkit/migrate' in this and other environments to migrate the content.\n\n"); + return ExitCode::OK; + + } + + + /** + * Convert LinkIt settings structure to native Link field settings structure + * + * @param array $linkitSettings + * @return array + */ + private function convertSettings(array $linkitSettings): array + { + $typeMapping = [ + 'presseddigital\\linkit\\models\\Entry' => 'entry', + 'presseddigital\\linkit\\models\\Asset' => 'asset', + 'presseddigital\\linkit\\models\\Category' => 'category', + 'presseddigital\\linkit\\models\\User' => 'user', + 'presseddigital\\linkit\\models\\Email' => 'email', + 'presseddigital\\linkit\\models\\Phone' => 'tel', + 'presseddigital\\linkit\\models\\Url' => 'url', + // Social media types merge into URL + 'presseddigital\\linkit\\models\\Twitter' => 'url', + 'presseddigital\\linkit\\models\\Facebook' => 'url', + 'presseddigital\\linkit\\models\\Instagram' => 'url', + 'presseddigital\\linkit\\models\\LinkedIn' => 'url', + ]; + + $types = []; + $typeSettings = []; + $advancedFields = []; + + // Add target if allowTarget was enabled + if (!empty($linkitSettings['allowTarget'])) { + $advancedFields[] = 'target'; + } + + // Process each type from the old structure + if (isset($linkitSettings['types']) && is_array($linkitSettings['types'])) { + foreach ($linkitSettings['types'] as $oldTypeClass => $typeConfig) { + // Skip if not enabled + if (empty($typeConfig['enabled']) || $typeConfig['enabled'] === "") { + continue; + } + + // Map to new type name + if (!isset($typeMapping[$oldTypeClass])) { + continue; + } + + $newTypeName = $typeMapping[$oldTypeClass]; + + // Add to types array (avoiding duplicates for merged social types) + if (!in_array($newTypeName, $types)) { + $types[] = $newTypeName; + } + + // Configure type-specific settings + switch ($newTypeName) { + case 'entry': + $typeSettings['entry'] = [ + 'sources' => $typeConfig['sources'] ?? '*', + 'showUnpermittedSections' => '1', + 'showUnpermittedEntries' => '1', + ]; + break; + + case 'asset': + $typeSettings['asset'] = [ + 'sources' => $typeConfig['sources'] ?? '*', + 'allowedKinds' => '*', + 'showUnpermittedVolumes' => '1', + 'showUnpermittedFiles' => '1', + ]; + break; + + case 'category': + $typeSettings['category'] = [ + 'sources' => $typeConfig['sources'] ?? '*', + ]; + break; + + case 'url': + // Merge URL settings from Url type and social media types + if (!isset($typeSettings['url'])) { + $typeSettings['url'] = [ + 'allowRootRelativeUrls' => '0', + 'allowAnchors' => '0', + 'allowCustomSchemes' => '0', + ]; + } + + // If this is the main URL type, map its specific settings + if ($oldTypeClass === 'presseddigital\\linkit\\models\\Url') { + if (!empty($typeConfig['allowPaths'])) { + $typeSettings['url']['allowRootRelativeUrls'] = '1'; + } + if (!empty($typeConfig['allowHash'])) { + $typeSettings['url']['allowAnchors'] = '1'; + } + // Allow custom schemes if mailto or alias enabled + if (!empty($typeConfig['allowMailto']) || !empty($typeConfig['allowAlias'])) { + $typeSettings['url']['allowCustomSchemes'] = '1'; + } + } + break; + } + } + } + + // Build the new settings structure + $newSettings = [ + 'advancedFields' => $advancedFields, + 'fullGraphqlData' => true, + 'maxLength' => 255, + 'showLabelField' => !empty($linkitSettings['allowCustomText']), + 'typeSettings' => $typeSettings, + 'types' => $types, + ]; + + return $newSettings; + } + +} \ No newline at end of file diff --git a/src/console/controllers/MigrateController.php b/src/console/controllers/MigrateController.php new file mode 100644 index 0000000..b050202 --- /dev/null +++ b/src/console/controllers/MigrateController.php @@ -0,0 +1,190 @@ +from('{{%fields}}') + ->where(['type' => Link::class]) + ->all(); + + foreach ($fields as $field) { + $this->stdout("\nPreparing to migrate field '{$field['handle']}' ({$field['uid']}) content.\n"); + + $fieldLayouts = (new Query()) + ->from('{{%fieldlayouts}}') + ->where(['like', 'config', $field['uid']]) + ->all(); + + foreach($fieldLayouts as $fieldLayout) { + $element = $this->findField(json_decode($fieldLayout['config'], true), $field['uid']); + + if ($element){ + $contentEntries = (new Query()) + ->from('{{%elements_sites}}') + ->where(['like', 'content', $element['uid']]) + ->all(); + + if (count($contentEntries) < 1) { + $this->stdout(" > No content to migrate for field '{$field['handle']}'\n", Console::FG_YELLOW); + continue; + } + + foreach($contentEntries as $contentEntry) { + $newContent = $this->convertContent($contentEntry['content'], $contentEntry['siteId'], $element['uid'] ); + if ($newContent !== false){ + Db::update('{{%elements_sites}}', + ['content' => $newContent], + [ + 'elementId' => $contentEntry['elementId'], + 'siteId' => $contentEntry['siteId'] + ]); + $this->stdout(" > Migrated content for element #{$contentEntry['elementId']}\n"); + } else { + $this->stdout(" > Unable to convert content for element #{$contentEntry['elementId']}\n", Console::FG_RED); + } + } + } + } + $this->stdout(" > Field '{$field['handle']}' content migrated.\n", Console::FG_GREEN); + } + + $this->stdout("\nLinkIt content conversion is complete.\n", Console::FG_GREEN); + + return ExitCode::OK; + } + + /** + * Find and return the matching field within the included $data + * + * @param $data The JSON data to find the field in + * @param $fieldUid the field UID to find + * @return false|mixed The found field or false if not found + */ + + private function findField($data, $fieldUid){ + $foundElement = false; + + foreach ($data['tabs'] as &$tab) { + foreach ($tab['elements'] as &$element) { + if (isset($element['fieldUid']) && $element['fieldUid'] === $fieldUid) { + $foundElement = &$element; + break 2; // Break out of loop + } + } + } + + return $foundElement; + } + + /** + * Convert LinkIt content format to native Link field format + * + * @param string $contentJson The JSON string from the content column + * @param string $fieldUid The field UID to update + * @return string The converted JSON string + */ + private function convertContent(string $contentJson, int $siteId, string $fieldUid): array|false + { + $content = json_decode($contentJson, true); + + if (!$content || !isset($content[$fieldUid])) { + return false; + } + + $linkitData = $content[$fieldUid]; + + // If it's already been converted, return + if (isset($linkitData['type']) && str_starts_with($linkitData['type'], 'presseddigital') === false){ + return false; + } + + // If it's not valid LinkIt data, return + if (!$linkitData || !isset($linkitData['type']) || !isset($linkitData['value'])) { + return false; + } + + $typeMapping = [ + 'presseddigital\\linkit\\models\\Entry' => 'entry', + 'presseddigital\\linkit\\models\\Asset' => 'asset', + 'presseddigital\\linkit\\models\\Category' => 'category', + 'presseddigital\\linkit\\models\\User' => 'user', + 'presseddigital\\linkit\\models\\Email' => 'email', + 'presseddigital\\linkit\\models\\Phone' => 'tel', + 'presseddigital\\linkit\\models\\Url' => 'url', + 'presseddigital\\linkit\\models\\Twitter' => 'url', + 'presseddigital\\linkit\\models\\Facebook' => 'url', + 'presseddigital\\linkit\\models\\Instagram' => 'url', + 'presseddigital\\linkit\\models\\LinkedIn' => 'url', + ]; + + // Map the type + $oldType = $linkitData['type']; + $newType = $typeMapping[$oldType] ?? 'url'; + + // Build the new Link field structure + $newLinkData = [ + 'type' => $newType, + 'label' => $linkitData['customText'] ?? null, + 'value' => $this->convertValueForType($newType, $linkitData['value'], $siteId), + ]; + + // Add target if present + if (!empty($linkitData['target'])) { + $newLinkData['target'] = $linkitData['target']; + } + + // Update the content with the new format + $content[$fieldUid] = $newLinkData; + +// return json_encode($content, JSON_UNESCAPED_SLASHES ); + return $content; + } + + /** + * Convert value based on type - element types need special reference format + * + * @param string $type The new type (entry, asset, category, etc.) + * @param mixed $value The original value + * @return string The converted value + */ + private function convertValueForType(string $type, $value, int $siteId): string + { + // Element types need the special {type:id@siteId:url} format + switch ($type) { + case 'entry': + return "{{entry:{$value}@{$siteId}:url}}"; + case 'asset': + return "{{asset:{$value}@{$siteId}:url}}"; + case 'category': + return "{{category:{$value}@{$siteId}:url}}"; + case 'user': + return "{{user:{$value}@{$siteId}:url}}"; + case 'email': + return "mailto:{$value}"; + case 'tel': + return "tel:{$value}"; + default: + // For URL and other types, return as-is + return (string) $value; + } + } +} \ No newline at end of file diff --git a/src/models/Phone.php b/src/models/Phone.php index 8249de0..dfafd52 100644 --- a/src/models/Phone.php +++ b/src/models/Phone.php @@ -32,7 +32,7 @@ public static function defaultPlaceholder(): string public function getUrl(): string { - return (string) 'tel:' . StringHelper::stripWhitespace($this->value); + return (string) 'tel:' . StringHelper::stripWhitespace($this->value ?? ''); } /**