Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FR: Add migration from Fruit Link It field #51

Closed
Mosnar opened this issue Feb 9, 2019 · 5 comments
Closed

FR: Add migration from Fruit Link It field #51

Mosnar opened this issue Feb 9, 2019 · 5 comments

Comments

@Mosnar
Copy link

Mosnar commented Feb 9, 2019

Those of us coming from Craft 2 probably used the Fruit Studio's Link It plugin, which was free at the time. While I think it's fair for developers to want to be paid, I think their price point is pretty silly (and honestly in my opinion, it ought to be free if we're trying to invest in Craft CMS as a community). We should add a CLI option or utility to convert Link It fields to typed link fields from either Craft 2 or Craft 3.

@sebastian-lenz
Copy link
Owner

If you have any guides, docs or scripts that would help other users with the migration we can include them with this package, feel free to open a pull request to add those.

@Mosnar
Copy link
Author

Mosnar commented Oct 6, 2019

I've been working on this for a recent migration. It's unfortunately a pretty big pain in the butt to do. I'm going to go ahead and post my progress here as I proceed in case someone else finds it useful.

These methods assume you've installed Link It in Craft 3 and run the migration. It can be safely uninstalled after that migration has ran.

Updating field settings - tested and working on basic link types and settings:

    /**
     * Convert field settings for a LinkIt field to a Typed Link Field.
     * Run Craft::$app->fields->saveField() on the output of this method
     * 
     * @param $settings - array of JSON decoded settings
     * @param $field - array for row in craft_fields table
     * @return LinkField
     */
    private function processFieldSettings($settings, $field)
    {
        $typeMap = [
            'fruitstudios\\linkit\\models\\Email' => 'email',
            'fruitstudios\\linkit\\models\\Url' => 'url',
            'fruitstudios\\linkit\\models\\Entry' => 'entry',
            'fruitstudios\\linkit\\models\\Asset' => 'asset',
        ];

        $newField = new LinkField();
        $newField->allowCustomText = $settings['allowCustomText'] ?? true;
        $newField->defaultText = $settings['defaultText'] ?? '';
        $newField->allowTarget = $settings['allowTarget'] ?? true;
        $newField->allowedLinkNames = [];
        $types = $settings['types'];
        foreach ($types as $typeClass => $typeSettings) {
            if (!array_key_exists($typeClass, $typeMap)) {
                print('Failed to find class for link type: ' . $typeClass);
                continue;
            }
            $mappedType = $typeMap[$typeClass];
            $newField->allowedLinkNames[] = $mappedType;
            $settings = [];
            if (array_key_exists('sources', $typeSettings)) {
                $settings['sources'] = $typeSettings['sources'];
            }
            $newField->typeSettings[$mappedType] = $settings;
        }
        $newField->defaultLinkName = $newField->allowedLinkNames[0];

        $newField->id = $field['id'];
        $newField->groupId = $field['groupId'];
        $newField->name = $field['name'];
        $newField->handle = $field['handle'];
        $newField->context = $field['context'];
        $newField->instructions = $field['instructions'];
        $newField->searchable = $field['searchable'];
        $newField->translationKeyFormat = $field['translationKeyFormat'];
        $newField->uid = $field['uid'];

        return $newField;
    }

Updating content table. Works on global context fields and Neo fields. Doesn't work on Matrix fields.

    /**
     * Processes globally scoped link field settings. Does not work on matrix fields yet
     * 
     * @param LinkField $field
     */
    private function processFieldContent($field)
    {
        $handle = $field->handle;
        $prefix = Craft::$app->content->fieldColumnPrefix;
        $column = $prefix . $handle;
        switch ($field->context) {
            case 'global':
                $content = (new Query())->select([$column, 'id', 'elementId'])
                    ->from(Table::CONTENT)
                    ->where(['not', [$column => null]])
                    ->andWhere(['not', [$column => '']])
                    ->all();
                foreach ($content as $row) {
                    $oldSettings = \GuzzleHttp\json_decode($row[$column], true);
                    $settings = new Link(['linkField' => $field]);
                    $settings->type = $oldSettings['type']; // yay it's the same
                    switch ($oldSettings['type']) {
                        case 'custom':
                            $settings->value = $oldSettings['custom'];
                            break;
                        case 'entry':
                            $settings->value = array_pop($oldSettings['entry']);
                            break;
                        case 'category':
                            $settings->value = array_pop($oldSettings['category']);
                            break;
                        case 'asset':
                            $settings->value = array_pop($oldSettings['asset']);
                            break;
                        case 'email':
                            $settings->value = $oldSettings['email'];
                            break;
                    }
                    if (array_key_exists('target', $oldSettings)) {
                        $settings->target = $oldSettings['target'];
                    }

                    if (array_key_exists('title', $oldSettings)) {
                        $settings->title = $oldSettings['title'];
                    }

                    $newVal = \GuzzleHttp\json_encode($settings->toArray());
                    $qb = Craft::$app->db;
                    $updated = $qb->createCommand()->update(Table::CONTENT, [$column => $newVal], ['id' => $row['id']]);
                    print('Updated content row ' . $row['id'] . ' for element ' . $row['elementId'] . PHP_EOL);
                }
        }
    }

@sebastian-lenz
Copy link
Owner

I'll close this issue due to inactivity. I've added a link to this issue in the readme for others that run into the same problem.

@engram-design
Copy link

engram-design commented Oct 22, 2022

To further the solution from @Mosnar I've created a console command others might find useful. The main difference is that it will switch the field from LinkIt to TypedLink instead of creating a new field. It also supports Matrix and Super Table.

This is coming from Craft 2.9.2, LinkIt 0.9.1 to Craft 3.7.57, Typed Link 1.0.25. I realise that Linkit 0.9.1 might be out of date on Craft 2 already, but that's what most of our sites had installed. So there are some API changes compared to the answer above.

<?php
namespace modules\sitemigration\console\controllers;

use Craft;
use craft\db\Query;
use craft\helpers\App;
use craft\helpers\ArrayHelper;
use craft\helpers\Console;
use craft\helpers\ElementHelper;
use craft\helpers\Json;

use yii\console\Controller;
use yii\console\ExitCode;

use typedlinkfield\fields\LinkField;
use typedlinkfield\models\Link;

class LinkitController extends Controller
{
    // Public Methods
    // =========================================================================

    public function actionMigrate()
    {
        App::maxPowerCaptain();

        // Update both the field content and the field settings
        $this->processFieldSettings();
        $this->processFieldContent();

        $this->stderr('FINISHED' . PHP_EOL, Console::FG_GREEN);
    }

    private function processFieldSettings()
    {
        $db = Craft::$app->getDb()->createCommand();

        $fields = (new Query())
            ->from('{{%fields}}')
            ->where(['type' => 'Linkit_Linkit'])
            ->all();

        foreach ($fields as $field) {
            $settings = Json::decode($field['settings']);

            $text = ArrayHelper::remove($settings, 'text');
            $target = ArrayHelper::remove($settings, 'target');
            $targetLocale = ArrayHelper::remove($settings, 'targetLocale');
            $defaultText = ArrayHelper::remove($settings, 'defaultText');
            $types = ArrayHelper::remove($settings, 'types');
            $assetSources = ArrayHelper::remove($settings, 'assetSources');
            $categorySources = ArrayHelper::remove($settings, 'categorySources');
            $entrySources = ArrayHelper::remove($settings, 'entrySources');

            $settings['defaultLinkName'] = 'entry';
            $settings['allowCustomText'] = (bool)$text;
            $settings['allowTarget'] = (bool)$target;
            $settings['defaultText'] = $defaultText;

            if (is_array($types)) {
                $settings['allowedLinkNames'] = $types;
            }

            $settings['typeSettings'] = [
                'asset' => [
                    'sources' => $assetSources,
                ],
                'category' => [
                    'sources' => $categorySources,
                ],
                'entry' => [
                    'sources' => $entrySources,
                ],
            ];

            // Create a new Linked field instance to have the settings validated correctly
            $newField = new LinkField($settings);
            $newField->name = $field['name'];
            $newField->handle = $field['handle'];

            if (!$newField->validate()) {
                $this->stderr(Json::encode($newField->getErrors()) . PHP_EOL, Console::FG_RED);

                continue;
            }

            $db->update('{{%fields}}', ['type' => LinkField::class, 'settings' => Json::encode($newField->settings)], ['id' => $field['id']])->execute();

            $this->stderr('Migrated field #' . $field['id'] . PHP_EOL, Console::FG_YELLOW);
        }
    }

    private function processFieldContent()
    {
        $db = Craft::$app->getDb()->createCommand();

        $fields = (new Query())
            ->from('{{%fields}}')
            ->where(['type' => LinkField::class])
            ->all();

        foreach ($fields as $fieldData) {
            // Fetch the field model because we'll need it later
            $field = Craft::$app->getFields()->getFieldById($fieldData['id'], false);

            if ($field) {
                $column = ElementHelper::fieldColumnFromField($field);

                // Handle global field content
                if ($field->context === 'global') {
                    $content = (new Query())
                        ->select([$column, 'id', 'elementId'])
                        ->from('{{%content}}')
                        ->where(['not', [$column => null]])
                        ->andWhere(['not', [$column => '']])
                        ->all();

                    foreach ($content as $row) {
                        $settings = $this->convertModel($field, Json::decode($row[$column]));

                        if ($settings) {
                            $db->update('{{%content}}', [$column => Json::encode($settings)], ['id' => $row['id']])->execute();

                            $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                        }
                    }
                }

                // Handle Matrix field content
                if (strstr($field->context, 'matrixBlockType')) {
                    // Get the Matrix field, and the content table
                    $blockTypeUid = explode(':', $field->context)[1];

                    $matrixInfo = (new Query())
                        ->select(['fieldId', 'handle'])
                        ->from('{{%matrixblocktypes}}')
                        ->where(['uid' => $blockTypeUid])
                        ->one();

                    if ($matrixInfo) {
                        $matrixFieldId = $matrixInfo['fieldId'];
                        $matrixBlockTypeHandle = $matrixInfo['handle'];

                        $matrixField = Craft::$app->getFields()->getFieldById($matrixFieldId, false);

                        if ($matrixField) {
                            $column = ElementHelper::fieldColumn($field->columnPrefix, $matrixBlockTypeHandle . '_' . $field->handle, $field->columnSuffix);

                            $content = (new Query())
                                ->select([$column, 'id', 'elementId'])
                                ->from($matrixField->contentTable)
                                ->where(['not', [$column => null]])
                                ->andWhere(['not', [$column => '']])
                                ->all();

                            foreach ($content as $row) {
                                $settings = $this->convertModel($field, Json::decode($row[$column]));
                                
                                if ($settings) {
                                    $db->update($matrixField->contentTable, [$column => Json::encode($settings)], ['id' => $row['id']])->execute();
                                
                                    $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                                }
                            }
                        }
                    }
                }

                // Handle Super Table field content
                if (strstr($field->context, 'superTableBlockType')) {
                    // Get the Super Table field, and the content table
                    $blockTypeUid = explode(':', $field->context)[1];

                    $superTableFieldId = (new Query())
                        ->select(['fieldId'])
                        ->from('{{%supertableblocktypes}}')
                        ->where(['uid' => $blockTypeUid])
                        ->scalar();

                    $superTableField = Craft::$app->getFields()->getFieldById($superTableFieldId, false);

                    if ($superTableField) {
                        $column = ElementHelper::fieldColumnFromField($superTableField);

                        $content = (new Query())
                            ->select([$column, 'id', 'elementId'])
                            ->from($superTableField->contentTable)
                            ->where(['not', [$column => null]])
                            ->andWhere(['not', [$column => '']])
                            ->all();

                        foreach ($content as $row) {
                            $settings = $this->convertModel($field, Json::decode($row[$column]));

                            if ($settings) {
                                $db->update($superTableField->contentTable, [$column => Json::encode($settings)], ['id' => $row['id']])->execute();
                            
                                $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                            }
                        }
                    }
                }
            }
        }
    }

    private function convertModel($field, $oldSettings)
    {
        // Because we've already converted the field to the new Link field, but may not have done the content yet, check first
        // This allows us to re-trigger this migration any time we wish without messing up the field data
        if (array_key_exists('ariaLabel', $oldSettings)) {
            return false;
        }

        $settings = new Link(['linkField' => $field]);
        $settings->type = $oldSettings['type'];
        
        switch ($oldSettings['type']) {
            case 'entry':
                $settings->value = array_pop($oldSettings['entry']);
                break;
            case 'category':
                $settings->value = array_pop($oldSettings['category']);
                break;
            case 'asset':
                $settings->value = array_pop($oldSettings['asset']);
                break;
            case 'custom':
                $settings->value = $oldSettings['custom'];
                break;
            case 'email':
                $settings->value = $oldSettings['email'];
                break;
            case 'tel':
                $settings->value = $oldSettings['tel'];
                break;
        }

        if (array_key_exists('target', $oldSettings)) {
            $settings->target = $oldSettings['target'];
        }

        if (array_key_exists('text', $oldSettings)) {
            $settings->customText = $oldSettings['text'];
        }

        return $settings->toArray();
    }

}

@engram-design
Copy link

engram-design commented Oct 22, 2022

Oh, and here's a very similar one coming from LinkIt 2.3.4 on Craft 2.

<?php
namespace modules\sitemigration\console\controllers;

use Craft;
use craft\db\Query;
use craft\helpers\App;
use craft\helpers\ArrayHelper;
use craft\helpers\Console;
use craft\helpers\ElementHelper;
use craft\helpers\Json;

use yii\console\Controller;
use yii\console\ExitCode;

use typedlinkfield\fields\LinkField;
use typedlinkfield\models\Link;

class LinkitController extends Controller
{
    // Public Methods
    // =========================================================================

    public function actionMigrate()
    {
        App::maxPowerCaptain();

        // Update both the field content and the field settings
        
        // For LinkIt 2.x
        $this->processFieldSettings();
        $this->processFieldContent();

        $this->stderr('FINISHED' . PHP_EOL, Console::FG_GREEN);
    }

    private function processFieldSettings()
    {
        $db = Craft::$app->getDb()->createCommand();

        $fields = (new Query())
            ->from('{{%fields}}')
            ->where(['type' => 'FruitLinkIt'])
            ->all();

        foreach ($fields as $field) {
            $settings = Json::decode($field['settings']);

            $allowCustomText = ArrayHelper::remove($settings, 'allowCustomText');
            $allowTarget = ArrayHelper::remove($settings, 'allowTarget');
            $types = ArrayHelper::remove($settings, 'types');
            $assetSources = ArrayHelper::remove($settings, 'assetSources');
            $categorySources = ArrayHelper::remove($settings, 'categorySources');
            $entrySources = ArrayHelper::remove($settings, 'entrySources');
            $assetSelectionLabel = ArrayHelper::remove($settings, 'assetSelectionLabel');
            $categorySelectionLabel = ArrayHelper::remove($settings, 'categorySelectionLabel');
            $entrySelectionLabel = ArrayHelper::remove($settings, 'entrySelectionLabel');
            $defaultText = ArrayHelper::remove($settings, 'defaultText');

            $settings['defaultLinkName'] = 'entry';
            $settings['allowCustomText'] = (bool)$allowCustomText;
            $settings['allowTarget'] = (bool)$allowTarget;

            if (is_array($types)) {
                $settings['allowedLinkNames'] = $types;
            }

            $settings['typeSettings'] = [
                'asset' => [
                    'sources' => $assetSources,
                ],
                'category' => [
                    'sources' => $categorySources,
                ],
                'entry' => [
                    'sources' => $entrySources,
                ],
            ];

            // Create a new Linked field instance to have the settings validated correctly
            $newField = new LinkField($settings);
            $newField->name = $field['name'];
            $newField->handle = $field['handle'];

            if (!$newField->validate()) {
                $this->stderr(Json::encode($newField->getErrors()) . PHP_EOL, Console::FG_RED);

                continue;
            }

            $db->update('{{%fields}}', ['type' => LinkField::class, 'settings' => Json::encode($newField->settings)], ['id' => $field['id']])->execute();

            $this->stderr('Migrated field #' . $field['id'] . PHP_EOL, Console::FG_YELLOW);
        }
    }

    private function processFieldContent()
    {
        $db = Craft::$app->getDb()->createCommand();

        $fields = (new Query())
            ->from('{{%fields}}')
            ->where(['type' => LinkField::class])
            ->all();

        foreach ($fields as $fieldData) {
            // Fetch the field model because we'll need it later
            $field = Craft::$app->getFields()->getFieldById($fieldData['id'], false);

            if ($field) {
                $column = ElementHelper::fieldColumnFromField($field);

                // Handle global field content
                if ($field->context === 'global') {
                    $content = (new Query())
                        ->select([$column, 'id', 'elementId'])
                        ->from('{{%content}}')
                        ->where(['not', [$column => null]])
                        ->andWhere(['not', [$column => '']])
                        ->all();

                    foreach ($content as $row) {
                        $settings = $this->convertModel($field, Json::decode($row[$column]));

                        if ($settings) {
                            $db->update('{{%content}}', [$column => Json::encode($settings)], ['id' => $row['id']])->execute();

                            $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                        }
                    }
                }

                // Handle Matrix field content
                if (strstr($field->context, 'matrixBlockType')) {
                    // Get the Matrix field, and the content table
                    $blockTypeUid = explode(':', $field->context)[1];

                    $matrixInfo = (new Query())
                        ->select(['fieldId', 'handle'])
                        ->from('{{%matrixblocktypes}}')
                        ->where(['uid' => $blockTypeUid])
                        ->one();

                    if ($matrixInfo) {
                        $matrixFieldId = $matrixInfo['fieldId'];
                        $matrixBlockTypeHandle = $matrixInfo['handle'];

                        $matrixField = Craft::$app->getFields()->getFieldById($matrixFieldId, false);

                        if ($matrixField) {
                            $column = ElementHelper::fieldColumn($field->columnPrefix, $matrixBlockTypeHandle . '_' . $field->handle, $field->columnSuffix);

                            $content = (new Query())
                                ->select([$column, 'id', 'elementId'])
                                ->from($matrixField->contentTable)
                                ->where(['not', [$column => null]])
                                ->andWhere(['not', [$column => '']])
                                ->all();

                            foreach ($content as $row) {
                                $settings = $this->convertModel($field, Json::decode($row[$column]));
                                
                                if ($settings) {
                                    $db->update($matrixField->contentTable, [$column => Json::encode($settings)], ['id' => $row['id']])->execute();
                                
                                    $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                                }
                            }
                        }
                    }
                }

                // Handle Super Table field content
                if (strstr($field->context, 'superTableBlockType')) {
                    // Get the Super Table field, and the content table
                    $blockTypeUid = explode(':', $field->context)[1];

                    $superTableFieldId = (new Query())
                        ->select(['fieldId'])
                        ->from('{{%supertableblocktypes}}')
                        ->where(['uid' => $blockTypeUid])
                        ->scalar();

                    $superTableField = Craft::$app->getFields()->getFieldById($superTableFieldId, false);

                    if ($superTableField) {
                        $column = ElementHelper::fieldColumnFromField($superTableField);

                        $content = (new Query())
                            ->select([$column, 'id', 'elementId'])
                            ->from($superTableField->contentTable)
                            ->where(['not', [$column => null]])
                            ->andWhere(['not', [$column => '']])
                            ->all();

                        foreach ($content as $row) {
                            $settings = $this->convertModel($field, Json::decode($row[$column]));

                            if ($settings) {
                                $db->update($superTableField->contentTable, [$column => Json::encode($settings)], ['id' => $row['id']])->execute();
                            
                                $this->stderr('Migrating content #' . $row['id'] . ' for element #' . $row['elementId'] . PHP_EOL, Console::FG_YELLOW);
                            }
                        }
                    }
                }
            }
        }
    }

    private function convertModel($field, $oldSettings)
    {
        // Because we've already converted the field to the new Link field, but may not have done the content yet, check first
        // This allows us to re-trigger this migration any time we wish without messing up the field data
        if (array_key_exists('ariaLabel', $oldSettings)) {
            return false;
        }

        $settings = new Link(['linkField' => $field]);
        $settings->type = $oldSettings['type'];
        
        switch ($oldSettings['type']) {
            case 'entry':
                $settings->value = array_pop($oldSettings['entry']);
                break;
            case 'category':
                $settings->value = array_pop($oldSettings['category']);
                break;
            case 'asset':
                $settings->value = array_pop($oldSettings['asset']);
                break;
            case 'custom':
                $settings->value = $oldSettings['custom'];
                break;
            case 'email':
                $settings->value = $oldSettings['email'];
                break;
            case 'tel':
                $settings->value = $oldSettings['tel'];
                break;
        }

        if (array_key_exists('target', $oldSettings)) {
            $settings->target = $oldSettings['target'];
        }

        if (array_key_exists('customText', $oldSettings)) {
            $settings->customText = $oldSettings['customText'];
        }

        return $settings->toArray();
    }

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants