Skip to content

[K4] How to migrate content to native link field #88

@medienbaecker

Description

@medienbaecker

I created a Kirby CLI command for migrating content to the native link field. It's still a work in progress and may not work correctly if you've used more features of the link field than I have. Please run a dry run first and consider this a starting point. Feel free to expand on it and share your improvements.

The command automatically transforms this structure…

Link:
  type: url
  value: https://example.com

Link2:
  type: email
  value: [email protected]

…to this:

Link: https://example.com

Link2: mailto:[email protected]

It can also handle JSON content (blocks field) and turns this…

"link": {
  "type": "url",
  "value": "https://example.com"
}

…to this:

"link": "https://example.com"

Here's the command:

<?php

declare(strict_types=1);

use Kirby\CLI\CLI;

return [
    'description' => 'Migrate from Link plugin to native K4 link field',
    'args' => [
        'verbose' => [
            'shortPrefix' => 'v',
            'longPrefix' => 'verbose',
            'description' => 'Verbose output',
            'defaultValue' => false,
            'noValue' => true,
        ],
        'dryrun' => [
            'longPrefix' => 'dry-run',
            'description' => 'Dry run',
            'defaultValue' => false,
            'noValue' => true,
        ],
    ],
    'command' => static function (CLI $cli): void {
        $directory = kirby()->roots()->content();
        $processed = 0;
        $replacements = 0;
        $isDryRun = $cli->arg('dryrun');
        $isVerbose = $cli->arg('verbose');

        // Recursively iterate through all files in the content directory
        $filenames = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($directory),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($filenames as $filename) {
            $filepath = $filename->getPathname();
            $relpath = str_replace($directory . '/', '', $filepath);
            
            // Process only .txt files
            if ($filename->isDir() || pathinfo($filepath, PATHINFO_EXTENSION) !== 'txt') {
                continue;
            }

            $content = file_get_contents($filepath);
            $fileReplacements = 0;

            // Process YAML-like structure with JSON content
            $content = preg_replace_callback(
                '/^(\w+):\s*(\[.*\])$/ms',
                function ($matches) use (&$fileReplacements, $isVerbose, $cli, $relpath) {
                    $fieldName = $matches[1];
                    $jsonContent = $matches[2];
                    
                    $json = json_decode($jsonContent, true);
                    if (json_last_error() === JSON_ERROR_NONE) {
                        $json = transformNestedJson($json, $fileReplacements);
                        
                        if ($isVerbose && $fileReplacements > 0) {
                            $cli->out("  Transformed field '{$fieldName}' in {$relpath}");
                        }
                        
                        return $fieldName . ': ' . json_encode($json, JSON_UNESCAPED_SLASHES);
                    }
                    return $matches[0];
                },
                $content
            );

            // Write changes to file if not a dry run
            if (!$isDryRun && $fileReplacements > 0) {
                file_put_contents($filepath, $content);
            }

            // Output processing results
            if ($fileReplacements === 0 && $isVerbose) {
                $cli->out("⏩ [0] {$relpath}");
            } elseif ($fileReplacements > 0) {
                $cli->out("✅ [{$fileReplacements}] {$relpath}");
            }

            $processed++;
            $replacements += $fileReplacements;
        }

        $cli->success("Files processed: {$processed}, Total replacements: {$replacements}");
    }
];

/**
 * Recursively transform nested JSON structures, including those stored as strings
 *
 * @param mixed $data The data to transform
 * @param int $replacements Reference to the replacement counter
 * @return mixed The transformed data
 */
function transformNestedJson($data, &$replacements) {
    if (is_array($data)) {
        foreach ($data as $key => &$value) {
            if (is_array($value)) {
                $value = transformNestedJson($value, $replacements);
            } elseif (is_string($value) && $key === 'categories') {
                // Handle nested JSON stored as a string
                $nestedJson = json_decode($value, true);
                if (json_last_error() === JSON_ERROR_NONE) {
                    $value = json_encode(transformNestedJson($nestedJson, $replacements), JSON_UNESCAPED_SLASHES);
                }
            }
            // Check for link structure and transform if found
            if (is_array($value) && isset($value['type']) &&
                in_array($value['type'], ['url', 'page', 'file', 'email', 'tel'])) {
                if (!isset($value['value']) || empty($value['value'])) {
                    // If there's no value or it's empty, set the entire field to an empty string
                    $value = '';
                } else {
                    $value = transformLink($value['type'], $value['value']);
                }
                $replacements++;
            }
        }
    }
    return $data;
}

/**
 * Transform a single link based on its type
 *
 * @param string $type The type of the link (url, page, file, email, tel)
 * @param string $value The value of the link
 * @return string The transformed link value
 */
function transformLink(string $type, string $value): string {
    if (empty($value)) {
        return '';
    }
    switch ($type) {
        case 'email':
            return 'mailto:' . $value;
        case 'tel':
            return 'tel:' . $value;
        default:
            return $value;
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions