diff --git a/cms/contents/article/admin/_tags_field.html.twig b/cms/contents/article/admin/_tags_field.html.twig new file mode 100644 index 0000000..3bbbcdb --- /dev/null +++ b/cms/contents/article/admin/_tags_field.html.twig @@ -0,0 +1,130 @@ +{% trans_default_domain 'sfs_cms_contents' %} + +
+ {{ form_label(form.tags) }} + {{ form_errors(form.tags) }} + {{ form_widget(form.tags) }} + +
+
+ +
+ + + {% for tag in form.tags.vars.tag_suggestions %} + + {% endfor %} + + +
{{ 'admin_article.form.tags.help'|trans }}
+
+ + diff --git a/cms/contents/article/admin/create.html.twig b/cms/contents/article/admin/create.html.twig index 9d21fcd..b763aae 100644 --- a/cms/contents/article/admin/create.html.twig +++ b/cms/contents/article/admin/create.html.twig @@ -20,4 +20,4 @@ {{ form_rest(form) }} -{% endblock form_fields %} \ No newline at end of file +{% endblock form_fields %} diff --git a/cms/contents/article/admin/update.html.twig b/cms/contents/article/admin/update.html.twig index b4e3d98..4583612 100644 --- a/cms/contents/article/admin/update.html.twig +++ b/cms/contents/article/admin/update.html.twig @@ -4,6 +4,7 @@ {{ form_row(form.name) }} {% if form.extraData is defined %}{{ form_widget(form.extraData) }}{% endif %} {{ form_row(form.publishedAt) }} + {% if form.tags is defined %}{% include '@content/article/admin/_tags_field.html.twig' with {'form': form} %}{% endif %} {% if form.author is defined %}{{ form_row(form.author) }}{% endif %} {{ form_rest(form) }} -{% endblock form_fields %} \ No newline at end of file +{% endblock form_fields %} diff --git a/cms/contents/article/translations/sfs_cms_contents.en.yaml b/cms/contents/article/translations/sfs_cms_contents.en.yaml index f90205c..8c2f874 100644 --- a/cms/contents/article/translations/sfs_cms_contents.en.yaml +++ b/cms/contents/article/translations/sfs_cms_contents.en.yaml @@ -89,6 +89,7 @@ admin_article: status: "Status" publishDate: "Publish date" author: "Author" + tags: "Tags" indexing: title: "Indexing" noIndex: @@ -318,6 +319,9 @@ admin_article: description.label: "Excerpt" publishedAt.label: "Visible published date time" author.label: "Author" + tags.label: "Tags" + tags.help: "Press Enter or comma to add each tag. Existing similar tags are reused." + tags.placeholder: "Write a tag" version_form: note.label: "Note" diff --git a/cms/contents/article/translations/sfs_cms_contents.es.yaml b/cms/contents/article/translations/sfs_cms_contents.es.yaml index 7562eae..231e668 100644 --- a/cms/contents/article/translations/sfs_cms_contents.es.yaml +++ b/cms/contents/article/translations/sfs_cms_contents.es.yaml @@ -88,6 +88,7 @@ admin_article: status: "Estado" publishDate: "Fecha de publicación" author: "Autor" + tags: "Etiquetas" indexing: title: "Indexado" noIndex: @@ -317,6 +318,9 @@ admin_article: description.label: "Excerpt" publishedAt.label: "Fecha de publicación visible" author.label: "Autor" + tags.label: "Etiquetas" + tags.help: "Pulsa Enter o coma para añadir cada etiqueta. Si ya existe una parecida, se reutiliza la misma." + tags.placeholder: "Escribe una etiqueta" version_form: note.label: "Nota" diff --git a/config/doctrine-mapping/entities/ArticleContent.orm.xml b/config/doctrine-mapping/entities/ArticleContent.orm.xml index a5651fc..cec183c 100644 --- a/config/doctrine-mapping/entities/ArticleContent.orm.xml +++ b/config/doctrine-mapping/entities/ArticleContent.orm.xml @@ -13,6 +13,8 @@ + + diff --git a/config/services/services.yaml b/config/services/services.yaml index ffdc333..a8ece70 100644 --- a/config/services/services.yaml +++ b/config/services/services.yaml @@ -16,6 +16,16 @@ services: resource: '../../src/Form/*' tags: ['form.type'] + Softspring\CmsBlogPlugin\Form\Admin\Article\ArticleCreateForm: + arguments: + $translatableContext: '@Softspring\CmsBundle\Translator\TranslatableContext' + tags: ['form.type'] + + Softspring\CmsBlogPlugin\Form\Admin\Article\ArticleUpdateForm: + arguments: + $translatableContext: '@Softspring\CmsBundle\Translator\TranslatableContext' + tags: ['form.type'] + Softspring\CmsBlogPlugin\Manager\: resource: '../../src/Manager/*' diff --git a/src/Data/EntityTransformer/ArticleEntityTransformer.php b/src/Data/EntityTransformer/ArticleEntityTransformer.php index 98d97dd..c14ad2a 100644 --- a/src/Data/EntityTransformer/ArticleEntityTransformer.php +++ b/src/Data/EntityTransformer/ArticleEntityTransformer.php @@ -4,10 +4,10 @@ use DateTime; use Softspring\CmsBlogPlugin\Model\ArticleContentInterface; +use Softspring\CmsBundle\Model\ContentInterface; use Softspring\CmsDataPlugin\Data\EntityTransformer\ContentEntityTransformer; use Softspring\CmsDataPlugin\Data\Exception\InvalidElementException; use Softspring\CmsDataPlugin\Data\ReferencesRepository; -use Softspring\CmsBundle\Model\ContentInterface; class ArticleEntityTransformer extends ContentEntityTransformer { diff --git a/src/Form/Admin/Article/ArticleCreateForm.php b/src/Form/Admin/Article/ArticleCreateForm.php index 0e9b927..594a8c9 100644 --- a/src/Form/Admin/Article/ArticleCreateForm.php +++ b/src/Form/Admin/Article/ArticleCreateForm.php @@ -2,26 +2,23 @@ namespace Softspring\CmsBlogPlugin\Form\Admin\Article; -use ReflectionClass; +use Softspring\CmsBlogPlugin\Form\Type\ArticleTagsType; use Softspring\CmsBlogPlugin\Model\ArticleAuthorInterface; use Softspring\CmsBundle\Form\Admin\Content\ContentCreateForm; use Softspring\CmsBundle\Form\Type\UserType; -use Softspring\CmsBundle\Manager\ContentManagerInterface; use Softspring\CmsBundle\Translator\TranslatableContext; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\FormBuilderInterface; class ArticleCreateForm extends ContentCreateForm { - public function __construct(protected ContentManagerInterface $contentManager, TranslatableContext $translatableContext) + public function __construct(TranslatableContext $translatableContext) { parent::__construct($translatableContext); } public function buildForm(FormBuilderInterface $builder, array $options): void { - $entityClass = new ReflectionClass($this->contentManager->getTypeClass($options['content_config']['_id'])); - parent::buildForm($builder, $options); $builder->add('publishedAt', DateTimeType::class, [ @@ -29,7 +26,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'widget' => 'single_text', ]); - if ($entityClass->implementsInterface(ArticleAuthorInterface::class)) { + $builder->add('tags', ArticleTagsType::class); + + $dataClass = $options['data_class'] ?? null; + + if (is_string($dataClass) && is_a($dataClass, ArticleAuthorInterface::class, true)) { $builder->add('author', UserType::class, [ 'required' => true, ]); diff --git a/src/Form/Admin/Article/ArticleUpdateForm.php b/src/Form/Admin/Article/ArticleUpdateForm.php index 7d44a85..ff8ea51 100644 --- a/src/Form/Admin/Article/ArticleUpdateForm.php +++ b/src/Form/Admin/Article/ArticleUpdateForm.php @@ -2,26 +2,23 @@ namespace Softspring\CmsBlogPlugin\Form\Admin\Article; -use ReflectionClass; +use Softspring\CmsBlogPlugin\Form\Type\ArticleTagsType; use Softspring\CmsBlogPlugin\Model\ArticleAuthorInterface; use Softspring\CmsBundle\Form\Admin\Content\ContentUpdateForm; use Softspring\CmsBundle\Form\Type\UserType; -use Softspring\CmsBundle\Manager\ContentManagerInterface; use Softspring\CmsBundle\Translator\TranslatableContext; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\FormBuilderInterface; class ArticleUpdateForm extends ContentUpdateForm { - public function __construct(protected ContentManagerInterface $contentManager, TranslatableContext $translatableContext) + public function __construct(TranslatableContext $translatableContext) { parent::__construct($translatableContext); } public function buildForm(FormBuilderInterface $builder, array $options): void { - $entityClass = new ReflectionClass($this->contentManager->getTypeClass($options['content_config']['_id'])); - parent::buildForm($builder, $options); $builder->add('publishedAt', DateTimeType::class, [ @@ -29,7 +26,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'widget' => 'single_text', ]); - if ($entityClass->implementsInterface(ArticleAuthorInterface::class)) { + $builder->add('tags', ArticleTagsType::class); + + $dataClass = $options['data_class'] ?? null; + + if (is_string($dataClass) && is_a($dataClass, ArticleAuthorInterface::class, true)) { $builder->add('author', UserType::class, [ 'required' => true, ]); diff --git a/src/Form/Type/ArticleTagsType.php b/src/Form/Type/ArticleTagsType.php new file mode 100644 index 0000000..7408842 --- /dev/null +++ b/src/Form/Type/ArticleTagsType.php @@ -0,0 +1,56 @@ +addModelTransformer(new CallbackTransformer( + fn (?array $tags): string => json_encode($tags ?? [], JSON_THROW_ON_ERROR), + function (?string $tags): array { + if (empty($tags)) { + return []; + } + + $decodedTags = json_decode($tags, true); + + return is_array($decodedTags) ? $this->articleTagManager->canonicalizeTags($decodedTags) : []; + } + )); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['tag_suggestions'] = $options['tag_suggestions']; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'empty_data' => '[]', + 'tag_suggestions' => fn (Options $options): array => $this->articleTagManager->getExistingTags(), + ]); + $resolver->setAllowedTypes('tag_suggestions', 'array'); + } + + public function getParent(): string + { + return HiddenType::class; + } +} diff --git a/src/Manager/ArticleEntityDuplicator.php b/src/Manager/ArticleEntityDuplicator.php index 447f3d7..510a812 100644 --- a/src/Manager/ArticleEntityDuplicator.php +++ b/src/Manager/ArticleEntityDuplicator.php @@ -21,5 +21,6 @@ public function duplicateData(ContentInterface $oldContent, ContentInterface $ne { $newContent->setAuthor($oldContent->getAuthor()); $newContent->setPublishedAt($oldContent->getPublishedAt()); + $newContent->setTags($oldContent->getTags()); } } diff --git a/src/Manager/ArticleTagManager.php b/src/Manager/ArticleTagManager.php new file mode 100644 index 0000000..fdb7ff0 --- /dev/null +++ b/src/Manager/ArticleTagManager.php @@ -0,0 +1,91 @@ +contentManager->getRepository('article')->createQueryBuilder('a') + ->select('a.tags') + ->andWhere('a.tags IS NOT NULL') + ->getQuery() + ->getScalarResult(); + + $tags = []; + $seenTags = []; + + foreach ($rows as $row) { + $rowTags = $row['tags'] ?? null; + + if (is_string($rowTags)) { + $decodedTags = json_decode($rowTags, true); + $rowTags = is_array($decodedTags) ? $decodedTags : []; + } + + if (!is_array($rowTags)) { + continue; + } + + foreach ($rowTags as $tag) { + if (!is_scalar($tag)) { + continue; + } + + $normalizedTag = preg_replace('/\s+/', ' ', trim((string) $tag)) ?: ''; + + if ('' === $normalizedTag) { + continue; + } + + $normalizedKey = mb_strtolower($normalizedTag); + + if (isset($seenTags[$normalizedKey])) { + continue; + } + + $seenTags[$normalizedKey] = $normalizedTag; + $tags[] = $normalizedTag; + } + } + + natcasesort($tags); + + return array_values($tags); + } + + public function canonicalizeTags(array $tags): array + { + $existingTags = []; + + foreach ($this->getExistingTags() as $existingTag) { + $existingTags[mb_strtolower($existingTag)] = $existingTag; + } + + $canonicalTags = []; + + foreach ($tags as $tag) { + if (!is_scalar($tag)) { + continue; + } + + $normalizedTag = preg_replace('/\s+/', ' ', trim((string) $tag)) ?: ''; + + if ('' === $normalizedTag) { + continue; + } + + $normalizedKey = mb_strtolower($normalizedTag); + $canonicalTags[] = $existingTags[$normalizedKey] ?? $normalizedTag; + $existingTags[$normalizedKey] = $existingTags[$normalizedKey] ?? $normalizedTag; + } + + return $canonicalTags; + } +} diff --git a/src/Migrations/Version20230325161610.php b/src/Migrations/Version20230325161610.php index b2682a9..4178b6f 100644 --- a/src/Migrations/Version20230325161610.php +++ b/src/Migrations/Version20230325161610.php @@ -4,8 +4,8 @@ namespace Softspring\CmsBlogPlugin\Migrations; -use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20230325161610 extends AbstractMigration diff --git a/src/Migrations/Version20230325161836.php b/src/Migrations/Version20230325161836.php index cedff64..523921f 100644 --- a/src/Migrations/Version20230325161836.php +++ b/src/Migrations/Version20230325161836.php @@ -4,8 +4,8 @@ namespace Softspring\CmsBlogPlugin\Migrations; -use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20230325161836 extends AbstractMigration diff --git a/src/Migrations/Version20260525090000.php b/src/Migrations/Version20260525090000.php new file mode 100644 index 0000000..fa494eb --- /dev/null +++ b/src/Migrations/Version20260525090000.php @@ -0,0 +1,35 @@ +connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE cms_content_blog_article ADD tags JSON DEFAULT NULL'); + $this->addSql("UPDATE cms_content_blog_article SET tags = '[]' WHERE tags IS NULL"); + + return; + } + + $this->addSql("ALTER TABLE cms_content_blog_article ADD tags LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)'"); + $this->addSql("UPDATE cms_content_blog_article SET tags = '[]' WHERE tags IS NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE cms_content_blog_article DROP tags'); + } +} diff --git a/src/Model/ArticleContentInterface.php b/src/Model/ArticleContentInterface.php index f22f0cb..ea73446 100644 --- a/src/Model/ArticleContentInterface.php +++ b/src/Model/ArticleContentInterface.php @@ -10,4 +10,8 @@ interface ArticleContentInterface extends ContentInterface public function getPublishedAt(): ?DateTime; public function setPublishedAt(?DateTime $publishedAt = null): void; + + public function getTags(): array; + + public function setTags(array $tags): void; } diff --git a/src/Model/ArticleContentTrait.php b/src/Model/ArticleContentTrait.php index 2e0da59..e04622f 100644 --- a/src/Model/ArticleContentTrait.php +++ b/src/Model/ArticleContentTrait.php @@ -8,6 +8,8 @@ trait ArticleContentTrait { protected ?int $publishedAt = null; + protected ?array $tags = []; + public function getPublishedAt(): ?DateTime { return $this->publishedAt ? DateTime::createFromFormat('U', "{$this->publishedAt}") : null; @@ -17,4 +19,38 @@ public function setPublishedAt(?DateTime $publishedAt = null): void { $this->publishedAt = $publishedAt instanceof DateTime ? (int) $publishedAt->format('U') : null; } + + public function getTags(): array + { + return $this->tags ?? []; + } + + public function setTags(array $tags): void + { + $normalizedTags = []; + $seenTags = []; + + foreach ($tags as $tag) { + if (!is_scalar($tag)) { + continue; + } + + $normalizedTag = preg_replace('/\s+/', ' ', trim((string) $tag)) ?: ''; + + if ('' === $normalizedTag) { + continue; + } + + $normalizedKey = mb_strtolower($normalizedTag); + + if (isset($seenTags[$normalizedKey])) { + continue; + } + + $seenTags[$normalizedKey] = true; + $normalizedTags[] = $normalizedTag; + } + + $this->tags = $normalizedTags; + } }