kirby-link-field icon indicating copy to clipboard operation
kirby-link-field copied to clipboard

[K4] How to migrate content to native link field

Open medienbaecker opened this issue 7 months ago • 1 comments

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;
    }
}

medienbaecker avatar Jul 16 '24 13:07 medienbaecker