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

ckeditor/convert/matrix command #234

Merged
merged 26 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0aa27b4
started setting up the command
i-just Apr 4, 2024
b3d62ae
Merge branch '4.x' into feature/cms-1093-prosifymatrix-field-command
i-just May 24, 2024
6276348
selecting top level field html & cke config
i-just May 24, 2024
8816e8f
basic conversion
i-just May 29, 2024
d4a05e1
cleanup
i-just May 29, 2024
a4e8820
more cleanup
i-just May 29, 2024
2adc47a
ecs
i-just May 29, 2024
fff0b9e
Merge branch '4.x' into feature/cms-1093-prosifymatrix-field-command
i-just May 29, 2024
31abb19
phpstan
i-just May 29, 2024
7595634
tweaks
i-just May 31, 2024
3303310
ecs again
i-just May 31, 2024
15de28b
Merge branch '4.x' into feature/cms-1093-prosifymatrix-field-command
brandonkelly Aug 14, 2024
523a6f8
Split into two action classes
brandonkelly Aug 14, 2024
6291090
Merge branch '4.x' into feature/cms-1093-prosifymatrix-field-command
brandonkelly Aug 15, 2024
15854a4
Handle the content conversion with a migration
brandonkelly Aug 15, 2024
a7da221
Cleanup
brandonkelly Aug 15, 2024
4b9de96
Better migration output. + fix localization bug
brandonkelly Aug 15, 2024
9a419cc
CLI improvements
brandonkelly Aug 15, 2024
469b103
Improve entry type/field selection + add Markdown flavor choice
brandonkelly Aug 15, 2024
0dac676
Error message tweaks
brandonkelly Aug 15, 2024
f6dcd7f
ECS
brandonkelly Aug 15, 2024
a8b2bb6
“”
brandonkelly Aug 15, 2024
8ccacde
default entry type option
brandonkelly Aug 15, 2024
7651420
Rename stuff
brandonkelly Aug 15, 2024
ba18ff5
Release notes
brandonkelly Aug 15, 2024
cdfc31b
Fix PHPStan issues
brandonkelly Aug 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
## Unreleased

- CKEditor now requires Craft CMS 5.3+.
- Added the `ckeditor/convert/matrix` command. ([#234](https://github.com/craftcms/ckeditor/pull/234))
- CKEditor fields can now be merged together. ([#277](https://github.com/craftcms/ckeditor/pull/277))
- Added `craft\ckeditor\migrations\BaseConvertMatrixContentMigration`.
- Fixed a bug where CKEditor fields’ search keywords were including nested entries’ rendered partial templates rather than nested entries’ search keywords.

## 4.1.0 - 2024-06-12
Expand Down
288 changes: 288 additions & 0 deletions src/console/actions/ConvertMatrix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

namespace craft\ckeditor\console\actions;

use Craft;
use craft\base\FieldInterface;
use craft\ckeditor\CkeConfig;
use craft\ckeditor\console\controllers\ConvertController;
use craft\ckeditor\Field;
use craft\ckeditor\Plugin;
use craft\enums\PropagationMethod;
use craft\errors\OperationAbortedException;
use craft\fields\Matrix;
use craft\fields\PlainText;
use craft\helpers\Console;
use craft\helpers\FileHelper;
use craft\helpers\StringHelper;
use craft\models\EntryType;
use Illuminate\Support\Collection;
use yii\base\Action;
use yii\base\Exception;
use yii\console\ExitCode;
use yii\helpers\Markdown;

/**
* Converts a Matrix field to CKEditor
*
* @property ConvertController $controller
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 4.2.0
*/
class ConvertMatrix extends Action
{
/**
* Converts a Matrix field to CKEditor
*
* @param string $fieldHandle
* @return int
* @throws Exception
*/
public function run(string $fieldHandle): int
{
if (!$this->controller->interactive) {
$this->controller->stderr("The fields/merge command must be run interactively.\n");
return ExitCode::UNSPECIFIED_ERROR;
}

$fieldsService = Craft::$app->getFields();
$matrixField = $fieldsService->getFieldByHandle($fieldHandle);

if (!$matrixField) {
$this->controller->stdout("Invalid field handle: $fieldHandle\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}

if (!$matrixField instanceof Matrix) {
// otherwise, ensure we're dealing with a matrix field
$this->controller->stdout("“{$matrixField->name}” is not a Matrix field.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}

// we have the matrix field, let's set up the basics for the CKE field
try {
/** @var EntryType|null $htmlEntryType */
/** @var Field|PlainText|null $htmlField */
/** @var string $markdownFlavor */
/** @var bool $preserveHtmlEntries */
[$htmlEntryType, $htmlField, $markdownFlavor, $preserveHtmlEntries] = $this->prepareContentPopulation($matrixField);
} catch (OperationAbortedException) {
$this->controller->stdout("Field conversion aborted.\n", Console::FG_YELLOW);
return ExitCode::OK;
}

// get the CKEditor config
$ckeConfig = $this->ckeConfig($matrixField->name, $htmlField);

$this->controller->stdout("\n");

// create the CKEditor field
$ckeField = new Field([
'id' => $matrixField->id,
'uid' => $matrixField->uid,
'name' => $matrixField->name,
'handle' => $matrixField->handle,
'context' => $matrixField->context,
'instructions' => $matrixField->instructions,
'searchable' => $matrixField->searchable,
'translationMethod' => match ($matrixField->propagationMethod) {
PropagationMethod::None => Field::TRANSLATION_METHOD_SITE,
PropagationMethod::SiteGroup => Field::TRANSLATION_METHOD_SITE_GROUP,
PropagationMethod::Language => Field::TRANSLATION_METHOD_LANGUAGE,
PropagationMethod::Custom => Field::TRANSLATION_METHOD_CUSTOM,
default => Field::TRANSLATION_METHOD_NONE,
},
'translationKeyFormat' => $matrixField->propagationKeyFormat,
'entryTypes' => $matrixField->getEntryTypes(),
]);

// ensure the CKEditor config has a "New entry" button
if (!in_array('createEntry', $ckeConfig->toolbar)) {
$this->controller->do("Adding the `New entry` button to the `$ckeConfig->name` CKEditor config", function() use ($ckeConfig) {
$ckeConfig->toolbar[] = '|';
$ckeConfig->toolbar[] = 'createEntry';
if (!Plugin::getInstance()->getCkeConfigs()->save($ckeConfig)) {
throw new Exception('Couldn’t save the CKEditor config.');
}
});
}
$ckeField->ckeConfig = $ckeConfig->uid;

$this->controller->do("Saving the `$ckeField->name` field", function() use ($fieldsService, $ckeField) {
if (!$fieldsService->saveField($ckeField)) {
throw new Exception('Couldn’t save the field.');
}
});

$contentMigrator = Craft::$app->getContentMigrator();
$migrationName = sprintf('m%s_convert_%s_to_ckeditor', gmdate('ymd_His'), $ckeField->handle);
$migrationPath = "$contentMigrator->migrationPath/$migrationName.php";

$this->controller->do("Generating the content migration", function() use (
$ckeField,
$htmlEntryType,
$htmlField,
$markdownFlavor,
$preserveHtmlEntries,
$migrationName,
$migrationPath,
) {
$content = $this->controller->getView()->renderFile(__DIR__ . '/convert-matrix-migration.php.template', [
'namespace' => Craft::$app->getContentMigrator()->migrationNamespace,
'className' => $migrationName,
'ckeFieldUid' => $ckeField->uid,
'htmlEntryTypeUid' => $htmlEntryType?->uid,
'htmlFieldUid' => $htmlField?->layoutElement->uid,
'markdownFlavor' => $markdownFlavor,
'preserveHtmlEntries' => $preserveHtmlEntries,
], $this);
FileHelper::writeToFile($migrationPath, $content);
});

$this->controller->stdout(" → Running the content migration …\n");
$contentMigrator->migrateUp($migrationName);

$this->controller->success(sprintf(<<<EOD
Field converted to CKEditor. Commit `%s`
and your project config changes, and run `craft up` on other environments
for the changes to take effect.
EOD,
FileHelper::relativePath($migrationPath)
));

return ExitCode::OK;
}

private function prepareContentPopulation(Matrix $matrixField): array
{
$htmlEntryType = $this->htmlEntryType($matrixField);
if (!$htmlEntryType) {
return [null, null, 'none', false];
}

$customFields = $htmlEntryType->getFieldLayout()->getCustomFields();
$htmlField = $this->htmlField($customFields);

if ($htmlField instanceof PlainText) {
$flavors = array_keys(Markdown::$flavors);
$markdownFlavor = $this->controller->select(
$this->controller->markdownToAnsi("Which Markdown flavor should `$htmlField->name` fields be parsed with?"),
[...array_combine($flavors, $flavors), 'none'],
'original',
);
} else {
$markdownFlavor = 'none';
}

if (count($customFields) === 1) {
$preserveHtmlEntries = false;
} else {
$preserveHtmlEntries = $this->controller->confirm($this->controller->markdownToAnsi("Preserve `$htmlEntryType->name` entries alongside their extracted HTML?"));
}

return [$htmlEntryType, $htmlField, $markdownFlavor, $preserveHtmlEntries];
}

private function htmlEntryType(Matrix $matrixField): ?EntryType
{
$entryTypes = Collection::make($matrixField->getEntryTypes());

// look for entry types that have a CKEditor or Plain Text field
/** @var Collection<EntryType> $eligibleEntryTypes */
$eligibleEntryTypes = $entryTypes
->filter(function(EntryType $entryType) {
foreach ($entryType->getFieldLayout()->getCustomFields() as $field) {
if ($field instanceof Field || $field instanceof PlainText) {
return true;
}
}
return false;
})
->keyBy(fn(EntryType $entryType) => $entryType->handle);

if ($eligibleEntryTypes->isEmpty()) {
$this->controller->warning("`$matrixField->name` doesn’t have any entry types with CKEditor/Plain Text fields.");
if (!$this->controller->confirm('Continue anyway?', true)) {
throw new OperationAbortedException();
}
return null;
}

$this->controller->stdout("Which entry type should HTML content be extracted from?\n\n");

foreach ($eligibleEntryTypes as $entryType) {
$this->controller->stdout(sprintf(" - %s\n", $this->controller->markdownToAnsi("`$entryType->handle` ($entryType->name)")));
}

$this->controller->stdout("\n");
$choice = $this->controller->select('Choose:', [
...$eligibleEntryTypes->map(fn(EntryType $entryType) => $entryType->name)->all(),
'none' => 'None',
], $eligibleEntryTypes->count() === 1 ? $eligibleEntryTypes->keys()->first() : null);
if ($choice === 'none') {
return null;
}
return $eligibleEntryTypes->get($choice);
}

private function htmlField(array $customFields): Field|PlainText
{
/** @var Collection<Field|PlainText> $eligibleFields */
$eligibleFields = Collection::make($customFields)
->filter(fn(FieldInterface $field) => $field instanceof Field || $field instanceof PlainText)
->keyBy(fn(Field|PlainText $field) => $field->handle);

if ($eligibleFields->count() === 1) {
return $eligibleFields->first();
}

$this->controller->stdout("Which custom field?\n\n");

foreach ($eligibleFields as $field) {
$this->controller->stdout(sprintf(" - %s\n", $this->controller->markdownToAnsi("`$field->handle` ($field->name)")));
}

$this->controller->stdout("\n");
$choice = $this->controller->select('Choose:', $eligibleFields->map(fn(Field|PlainText $field) => $field->name)->all());
return $eligibleFields->get($choice);
}

private function ckeConfig(string $fieldName, Field|PlainText|null $htmlField = null): CkeConfig
{
$ckeConfigsService = Plugin::getInstance()->getCkeConfigs();

// if a CKEditor field was chosen to populate the converted field's content, use its CKEditor config
if ($htmlField instanceof Field && $htmlField->ckeConfig) {
return $ckeConfigsService->getByUid($htmlField->ckeConfig);
}

$ckeConfigs = Collection::make($ckeConfigsService->getAll())
->keyBy(fn(CkeConfig $ckeConfig) => StringHelper::slugify($ckeConfig->name))
->all();

// if existing CKEditor configs exist, ask which one they'd like to use
if (!empty($ckeConfigs)) {
$name = $this->controller->select('Which CKEditor config should be used for this field?', $ckeConfigs);
return $ckeConfigs[$name];
}

// otherwise, just create one with the default settings plus "New entry" button
$ckeConfig = new CkeConfig([
'uid' => StringHelper::UUID(),
'name' => $fieldName,
]);
$ckeConfig->toolbar[] = '|';
$ckeConfig->toolbar[] = 'createEntry';
$this->controller->do("Creating a CKEditor config", function() use ($ckeConfigsService, $ckeConfig) {
if (!$ckeConfigsService->save($ckeConfig)) {
throw new Exception('Couldn’t save the CKEditor config.');
}
});
return $ckeConfig;
}
}
Loading
Loading